openvibers/openvibe.codes Commits
Codes C4b: the task page's own panels, and the pickers that remember (#178)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++-- Codes C4b: a person's own workspace choices. The Settings screen remembers the default agent, the default machine,++-- their notifications and their safety level per person, so a signed-in visitor finds their choices where they left++-- them. One row per person; no other table holds a preference (run_tasks holds work, codes_attempts its attempts).++--++-- The row holds ids a person already sees (a harness id such as codex, a node principal nod_…, a level) and three++-- yes/no choices. It holds no secret: the provider keys live in OpenVibe.AI (codes_connections is not a table here).++++CREATE TABLE IF NOT EXISTS codes_user_settings (++ subject text COLLATE "C" PRIMARY KEY, -- usr_<ULID>++ default_agent text COLLATE "C", -- a harness id from the catalog, or null for Auto++ default_machine text COLLATE "C", -- a node principal (nod_…), or null for Auto++ notify_done boolean NOT NULL DEFAULT true, -- tell me when a task finishes++ notify_needs boolean NOT NULL DEFAULT true, -- tell me when a task needs me++ safety text COLLATE "C" NOT NULL DEFAULT 'ask', -- ask | project | full++ updated_at timestamptz NOT NULL DEFAULT now()++);@@@ -1,12 +1,12 @@{"name": "openvibe-codes",-- "version": "0.5.0",++ "version": "0.5.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-codes",-- "version": "0.5.0",++ "version": "0.5.1","license": "AGPL-3.0-only","dependencies": {"cookie-parser": "^1.4.7",@@@ -1,4 +1,4 @@--.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}++.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}@@@ -17,7 +17,7 @@.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}--.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:40px}++.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px}.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}@@@ -33,17 +33,123 @@.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}--.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:0 11px 0 9px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}++.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:0 14px 0 12px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}--@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:36px;padding:0 9px;font-size:11px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}--:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba}++@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}++:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}++++/* C4b: the row pattern (one calm row, pill actions), the fold-out panels, and the forms that remember choices. */++.c4-rows{display:grid;gap:0;list-style:none;margin:6px 0 0;padding:0}++.c4-row{display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:4px 10px;border-top:1px solid var(--c4-line);padding:2px 0;min-width:0}++.c4-row:first-child{border-top:0}++.c4-fold-row{border-top:1px solid var(--c4-line);min-width:0;list-style:none}++.c4-fold-row:first-child{border-top:0}++.c4-row-flat{padding:10px 0;min-height:52px}++.c4-row-dot{width:9px;height:9px;border-radius:50%;background:var(--c4-dot);grid-column:1;grid-row:1}++.c4-dot-ok{background:var(--c4-dot-ok)}.c4-dot-bad{background:var(--c4-dot-bad)}.c4-dot-skip{background:var(--c4-dot-skip)}.c4-dot-open{background:var(--c4-dot-open)}++.c4-row-label{grid-column:2;font-size:13px;font-weight:650;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.c4-row-note{grid-column:2;grid-row:2;color:var(--c4-muted);font-size:12px;line-height:1.4;min-width:0;overflow-wrap:anywhere}++.c4-row > .c4-pill,.c4-row > form{grid-column:3;grid-row:1/3;justify-self:end}++.c4-row-flat .c4-row-note{grid-row:auto}++.c4-row-flat > .c4-pill,.c4-row-flat > form{grid-row:1/3}++.c4-pill{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:3px 10px;border-radius:99px;background:var(--c4-quiet-bg);color:var(--c4-quiet-ink);font-size:11px;font-weight:650;white-space:nowrap;border:1px solid transparent}++.c4-pill-ok{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}.c4-pill-bad{background:var(--c4-bad-bg);color:var(--c4-bad-ink)}.c4-pill-skip{background:var(--c4-skip-bg);color:var(--c4-skip-ink)}.c4-pill-open{background:var(--c4-open-bg);color:var(--c4-open-ink)}.c4-pill-skill{background:var(--c4-quiet-bg);color:var(--c4-quiet-ink)}++.c4-fold{border:0;min-width:0}++.c4-fold > summary{display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:4px 10px;min-height:44px;padding:8px 0;cursor:pointer;list-style:none}++.c4-fold > summary::-webkit-details-marker{display:none}++.c4-fold > summary::marker{content:""}++.c4-fold > summary .c4-row-label{grid-column:2;grid-row:1}++.c4-fold > summary .c4-row-note{grid-column:2;grid-row:2}++.c4-fold > summary .c4-pill{grid-column:3;grid-row:1/3}++.c4-fold[open] > summary{border-bottom:1px solid var(--c4-line)}++.c4-fold-body{padding:10px 0 14px 22px;min-width:0}++.c4-shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;list-style:none;margin:0;padding:0}++.c4-shot{display:grid;gap:5px;justify-items:start;border:1px solid var(--c4-line);border-radius:10px;padding:8px;color:inherit;text-decoration:none;min-width:0}++.c4-shot img{width:100%;height:auto;border-radius:6px;display:block}++.c4-shot > span{font-size:11px;color:var(--c4-muted);overflow-wrap:anywhere}++.c4-shot svg{width:20px;height:20px;color:var(--c4-muted)}++.c4-shot-ghost{grid-template-columns:20px minmax(0,1fr);align-items:center;background:var(--c4-ghost)}++.c4-shot-why{margin:4px 0 0;font-size:11px;color:var(--c4-muted);overflow-wrap:anywhere}++.c4-filelist{list-style:none;margin:6px 0;padding:0;display:grid;gap:4px}++.c4-filelist > li{display:grid;grid-template-columns:minmax(0,1fr);gap:8px;align-items:center;font-size:12px;min-width:0}++.c4-filelist code{overflow-wrap:anywhere}++.c4-filelist-counts > li{grid-template-columns:minmax(0,1fr) auto auto}++.c4-add{color:#47713d;font-variant-numeric:tabular-nums}.c4-del{color:#995128;font-variant-numeric:tabular-nums}++.c4-diff{max-height:420px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:11.5px;line-height:1.5;white-space:pre;max-width:100%}++.c4-code-small{font-size:11.5px;max-height:260px;overflow:auto}++.c4-panel-lede{margin:2px 0 8px;font-size:13px;color:var(--c4-muted)}++.c4-kv{display:grid;gap:3px;margin:6px 0;font-size:12px;min-width:0}++.c4-kv > span{color:var(--c4-muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em}++.c4-kv code{overflow-wrap:anywhere;background:#f5f7f7;border-radius:6px;padding:6px 8px;display:block}++.c4-review{margin:8px 0 0;font-size:13px;line-height:1.5;color:var(--c4-ink);overflow-wrap:anywhere}++.c4-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:10px 0 0;padding:0}++.c4-facts > div{display:grid;gap:5px;min-width:0}++.c4-facts dt{font-size:11px;color:var(--c4-muted)}++.c4-facts dd{margin:0;font-size:13px}++.c4-pr-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0 4px}++.c4-inline-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}++.c4-opill-quiet{background:var(--c4-quiet);color:var(--c4-ink)}++.c4-form{display:grid;gap:12px;margin-top:10px}++.c4-form label{display:grid;gap:5px;font-size:12px;color:var(--c4-muted);min-width:0}++.c4-form input,.c4-form select{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:9px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px;min-width:0;width:100%}++.c4-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}++.c4-form-inline{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px}++.c4-checks{display:grid;gap:10px;margin-top:10px}++.c4-check{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:12px;cursor:pointer;min-height:64px}++.c4-check input{margin-top:3px;accent-color:var(--c4-accent);flex:none;width:18px;height:18px;min-height:0}++.c4-check span{display:grid;gap:4px}.c4-check small{color:var(--c4-muted);font-size:12px;line-height:1.4}++.c4-subhead{font-size:13px;margin:18px 0 0;color:var(--c4-ink)}++.c4-hint{margin:8px 0 0;font-size:12px;color:var(--c4-muted);line-height:1.5}++.c4-hint.is-bad{color:#995128}++.c4-notice{margin:0 0 14px;font-size:13px}++.c4-notice-bad{border-color:var(--c4-bad-line)}++.c4-table{width:100%;border-collapse:collapse;font-size:12.5px;font-variant-numeric:tabular-nums}++.c4-table th,.c4-table td{padding:9px 8px;border-top:1px solid var(--c4-line);text-align:right;white-space:nowrap}++.c4-table th:first-child,.c4-table td:first-child{text-align:left}++.c4-table thead th{color:var(--c4-muted);font-weight:600;border-top:0}++.c4-table tfoot th,.c4-table tfoot td{font-weight:700;border-top:1px solid var(--c4-line)}++.c4-steps{margin:0;padding-left:20px;display:grid;gap:8px;font-size:13px;line-height:1.5}++.c4-steps a{color:var(--c4-accent)}++.c4-code-inline{display:inline-block;max-width:100%;overflow-wrap:anywhere;background:#f5f7f7;border-radius:6px;padding:3px 6px;font-size:12px}++.c4-note-inline{color:var(--c4-muted)}++.c4-pair-command{display:grid;gap:6px;margin-top:10px}++.c4-saved{margin:10px 0 0}++.c4-saved .c4-chip svg{width:14px;height:14px;margin-right:5px}++.c4-card a.c4-opill,.c4-card a.c4-opill-quiet{color:var(--c4-ink)}++.c4-card a.c4-opill-primary{color:#fff}++:root[data-theme-mode="dark"] .c4-shot-ghost,:root[data-theme-mode="dark"] .c4-diff,:root[data-theme-mode="dark"] .c4-kv code,:root[data-theme-mode="dark"] .c4-code-inline{background:#11181b;color:#d3dfdf}++:root[data-theme-mode="dark"] .c4-opill-quiet{background:#20292d;color:var(--c4-ink)}++:root[data-theme-mode="dark"] .c4-form input,:root[data-theme-mode="dark"] .c4-form select{background:#11181b;color:var(--c4-ink)}++@media(max-width:760px){++ .c4-form-grid{grid-template-columns:1fr}.c4-form-inline{grid-template-columns:1fr}++ .c4-row{grid-template-columns:12px minmax(0,1fr)}.c4-row > .c4-pill,.c4-row > form,.c4-row-flat > .c4-pill,.c4-row-flat > form{grid-column:2;grid-row:auto;justify-self:start;margin-bottom:8px}++ .c4-fold > summary{grid-template-columns:12px minmax(0,1fr)}.c4-fold > summary .c4-pill{grid-column:2;grid-row:3;justify-self:start;margin-bottom:4px}++ .c4-fold-body{padding-left:0}.c4-shots{grid-template-columns:1fr 1fr}++ .c4-inline-actions{justify-content:flex-start}++}++/* C4b fixes: the shared safety/notification cards keep their own layout inside a form, and a radio stays a radio. */++.c4-form label.c4-safety-card,.c4-form label.c4-check{display:flex;align-items:flex-start;gap:10px}++.c4-form .c4-safety-card input,.c4-form .c4-check input{width:18px;height:18px;min-height:0;padding:0;border:0;background:none;accent-color:var(--c4-accent);flex:none;margin-top:3px}++@media(max-width:760px){.c4-controls .c4-field-repo{grid-column:1/-1}}++@media(max-width:760px){++ .c4-row > .c4-inline-actions{grid-column:2;grid-row:auto;justify-content:flex-start;margin:2px 0 8px}++ .c4-card-head{flex-wrap:wrap}++ .c4-card-head > .c4-muted{flex:1 1 100%}++}++.c4-hint-status{display:block;margin-top:4px}++.c4-hint-status:empty{display:none}++/* C4b: on a touch screen every tappable thing a person reaches for is at least 44 px tall. */++@media(pointer:coarse){++ .c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}++ .c4-card-head a{display:inline-flex;align-items:center;min-height:44px}++}@@@ -8,7 +8,8 @@links.forEach((link) => link.setAttribute('aria-selected', String(link.dataset.c4Tab === id)));panels.forEach((panel) => { panel.hidden = panel.id !== id; });};-- activate('live');++ const chosen = links.find((link) => link.getAttribute('aria-selected') === 'true');++ activate(chosen ? chosen.dataset.c4Tab : 'live');links.forEach((link) => link.addEventListener('click', (event) => {event.preventDefault();activate(link.dataset.c4Tab);@@@ -92,7 +93,8 @@if (reason) reason.textContent = outcome.note || task.error || 'The task stopped before it was complete.';}const chip = detail.querySelector('.c4-task-heading > .c4-status');-- if (chip) chip.textContent = task.state || 'Unknown';++ // The words the page uses, not Run's own state id.++ if (chip) chip.textContent = ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' })[task.state] || task.state || 'Unknown';if (label) label.textContent = ['succeeded', 'failed', 'cancelled', 'expired'].includes(task.state) ? 'This task has finished.' : 'Connecting to the live task…';};if (detail) fetch(detail.dataset.taskApi, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })@@@ -142,4 +144,43 @@if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }});});++ // The repository picker: the person's own repositories first, then GitHub's public answer for what they type.++ // Server-rendered options already hold the saved ones, so the field works with this script blocked.++ document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {++ const list = document.getElementById(input.getAttribute('list'));++ const status = input.closest('form')?.querySelector('[data-c4-repo-status]');++ const url = input.dataset.c4RepoUrl;++ if (!list || !url) return;++ let timer = 0;++ let seq = 0;++ const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };++ const search = async () => {++ const q = input.value.trim();++ if (q.length < 2 || /^https?:\/\//i.test(q)) { say(''); return; }++ const mine = ++seq;++ say('Searching your repositories…');++ try {++ const res = await fetch(`${url}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });++ if (!res.ok) throw new Error(`search answered ${res.status}`);++ const doc = await res.json();++ if (mine !== seq) return;++ list.textContent = '';++ for (const repo of doc.repos || []) {++ const option = document.createElement('option');++ option.value = repo.url;++ option.label = repo.full_name + (repo.recent ? ' · used here' : '');++ list.append(option);++ }++ const found = (doc.repos || []).length;++ if (doc.problem) say(doc.problem.detail || 'GitHub could not be searched', true);++ else say(found ? `${found} repositor${found === 1 ? 'y' : 'ies'}; pick one or paste a URL.` : 'No repository matched. Paste its URL.');++ } catch (e) {++ if (mine === seq) say('Search is not available; paste the repository URL.', true);++ }++ };++ input.addEventListener('input', () => {++ window.clearTimeout(timer);++ timer = window.setTimeout(search, 350);++ });++ });})();@@@ -46,6 +46,10 @@ const { createMovedRoutes } = require('./http/moved');const { createCodesReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');const { createForgejoClient } = require('./domain/forgejo');++const { createMachines } = require('./domain/machines');++const { createUserSettings } = require('./domain/user-settings');++const { createGitHub } = require('./domain/github');++const { createUsage } = require('./domain/usage');const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');@@@ -81,6 +85,11 @@ async function createApp(opts = {}) {const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, benchPublisher };ctx.forgejo = createForgejoClient({ config, log });++ // The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.++ ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });++ ctx.userSettings = createUserSettings({ store });++ ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });++ ctx.usage = createUsage({ store });const app = express();app.disable('x-powered-by');@@@ -181,7 +190,7 @@ async function createApp(opts = {}) {// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).app.use('/api/v1', createApi(ctx));app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));-- app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));@@@ -110,6 +110,10 @@ function load(env = process.env) {// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },++ // GitHub (Codes C4b): the repository search behind the Home picker, and the CI and merge state beside a task's++ // pull request. Read-only, public answers; the token only raises GitHub's limits and is never shown or logged.++ github: { token: String(env.CODES_GITHUB_TOKEN || '').trim() },++// A task's own budget (engine/budget.js): steps (tool calls), minutes (wall clock) and dollars. A limit that is// not set is no limit — 0 means "no limit", never a zero budget. Steps and minutes buy one wrap-up turn.budget: {nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++++/**++ * Codes' small GitHub reader (Codes C4b): which repositories a person may pick for a task, and what happened to the++ * pull request a task opened.++ *++ * Two rules shape it. The first is that Codes holds no GitHub account of a person: there is no per-person GitHub link++ * in OpenVibe.Network today, so a repository is searched on GitHub's public repository search, narrowed to the person's++ * own repositories when they have used them here before. The second is that a pull request's state is GitHub's, not++ * Codes': the link comes from the task's own result, and its CI and merge state are read from api.github.com and shown++ * as read, or said not to have been read — never guessed.++ *++ * createGitHub({ config, store, fetchImpl, log })++ * search(subject, q) → { recent, found, problem }: repositories, the person's own first++ * pullRequest(url) → { url, state, ci, mergeable, checked_at } or { url, problem }++ *++ * The optional token (CODES_GITHUB_TOKEN) raises GitHub's limits; it is never logged, never in an answer and never sent++ * anywhere but api.github.com.++ */++++const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,38})$/;++const NAME = /^[A-Za-z0-9._-]{1,100}$/;++const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d{1,10})\/?$/;++const REPO_URL = /^(?:https:\/\/|git@)github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i;++const TTL_MS = 2 * 60_000;++const FAIL_MS = 20_000;++const SEARCH_TTL_MS = 60_000;++const MAX_FOUND = 8;++++/** A repository URL as Codes stores it (https, no .git), or null when it is not a GitHub one. */++function githubRepo(url) {++ const m = REPO_URL.exec(String(url || '').trim());++ return m && OWNER.test(m[1]) && NAME.test(m[2]) ? { owner: m[1], name: m[2], url: `https://github.com/${m[1]}/${m[2]}` } : null;++}++++/** A pull request URL's parts, or null when it is not one. */++function pullRequestParts(url) {++ const m = PR_URL.exec(String(url || '').trim());++ return m ? { owner: m[1], name: m[2], number: Number(m[3]) } : null;++}++++/** The merge state in the words a person reads. */++function mergeWord(pr) {++ if (!pr) return null;++ if (pr.merged) return 'Merged';++ if (pr.state === 'closed') return 'Closed without merging';++ if (pr.draft) return 'Draft';++ if (pr.mergeable === false) return 'Has conflicts';++ if (pr.mergeable_state === 'blocked') return 'Waiting for checks or a review';++ if (pr.mergeable_state === 'behind') return 'Behind its base branch';++ if (pr.mergeable_state === 'clean' || pr.mergeable === true) return 'Ready to merge';++ return 'Open';++}++++/** The CI state in the words a person reads, from GitHub's combined commit status. */++function ciWord(status) {++ const state = status && (status.state || status.status);++ return ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[state] || null;++}++++function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {++ const token = String((config.github && config.github.token) || '').trim();++ const cache = new Map();++ const searches = new Map();++++ const headers = () => ({++ Accept: 'application/vnd.github+json',++ 'User-Agent': 'openvibe-codes',++ ...(token ? { Authorization: `Bearer ${token}` } : {}),++ });++++ async function getJson(path) {++ const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(6000) });++ const doc = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, doc };++ }++++ /** The person's own repositories used here, newest first (a URL is enough: Codes stored them already). */++ async function recent(subject, limit = MAX_FOUND) {++ if (!store || !subject) return [];++ const rows = await store.db.many(++ `SELECT repo, max(created_at) AS at FROM run_tasks WHERE subject = $1 AND repo LIKE 'https://github.com/%' GROUP BY repo ORDER BY at DESC LIMIT $2`,++ [subject, limit],++ );++ return rows.map((r) => githubRepo(r.repo)).filter(Boolean)++ .map((r) => ({ full_name: `${r.owner}/${r.name}`, url: r.url, recent: true }));++ }++++ /**++ * Search: the person's own repositories first (the ones they used here, then GitHub's search for what they typed).++ * A GitHub that does not answer leaves the answers Codes already knows, and says it could not search.++ *++ * The answer is cached for a minute: a person typing the same letters again should not spend GitHub's budget twice.++ */++ async function search(subject, q, { limit = MAX_FOUND } = {}) {++ const query = String(q || '').trim().slice(0, 100);++ const key = `${subject || ''}|${query.toLowerCase()}|${limit}`;++ const hit = searches.get(key);++ if (hit && Date.now() - hit.at < SEARCH_TTL_MS) return hit.value;++ const value = await searchNow(subject, query, limit);++ searches.set(key, { at: Date.now(), value });++ if (searches.size > 200) searches.delete(searches.keys().next().value);++ return value;++ }++++ async function searchNow(subject, query, limit) {++ const mine = await recent(subject, limit);++ const found = [];++ let problem = null;++ const qq = query.replace(/[^A-Za-z0-9._/-]+/g, ' ').trim();++ if (qq) {++ const path = `/search/repositories?q=${encodeURIComponent(`${qq} in:name`)}&sort=stars&order=desc&per_page=${limit}`;++ try {++ const out = await getJson(path);++ if (out.ok && out.doc && Array.isArray(out.doc.items)) {++ for (const item of out.doc.items.slice(0, limit)) {++ if (!item || !item.full_name || item.private) continue;++ found.push({++ full_name: String(item.full_name).slice(0, 140),++ url: String(item.html_url || `https://github.com/${item.full_name}`).slice(0, 300),++ description: item.description ? String(item.description).slice(0, 160) : null,++ updated_at: item.updated_at || null,++ stars: Number(item.stargazers_count) || 0,++ });++ }++ } else {++ problem = {++ status: out.status === 403 || out.status === 429 ? 429 : 502,++ detail: out.status === 403 || out.status === 429++ ? 'GitHub limits how many searches one address may make; the repositories you used here still work'++ : `GitHub answered ${out.status} for the search`,++ };++ }++ } catch (e) {++ problem = { status: 503, detail: `GitHub did not answer: ${String(e && e.message).slice(0, 120)}` };++ }++ }++ // The person's own repositories that match what they typed come first, then GitHub's public answer.++ const asked = query.toLowerCase();++ const own = mine.filter((r) => !asked || r.full_name.toLowerCase().includes(asked));++ const seen = new Set(own.map((r) => r.full_name.toLowerCase()));++ const rest = found.filter((r) => !seen.has(r.full_name.toLowerCase()));++ return { q: query, recent: mine, found: [...own, ...rest].slice(0, limit), problem };++ }++++ /**++ * A pull request's state, as GitHub has it now. Cached for two minutes (a failure for twenty seconds), so a page a++ * person reloads does not spend GitHub's budget. Never throws: a read that failed is a problem, and the page shows++ * the link without the state.++ */++ async function pullRequest(url) {++ const parts = pullRequestParts(url);++ if (!parts) return { url: String(url || ''), problem: { status: 422, detail: 'that is not a GitHub pull request' } };++ const key = `${parts.owner}/${parts.name}#${parts.number}`;++ const hit = cache.get(key);++ if (hit && Date.now() - hit.at < (hit.problem ? FAIL_MS : TTL_MS)) return hit.value;++ let value;++ try {++ const pr = await getJson(`/repos/${parts.owner}/${parts.name}/pulls/${parts.number}`);++ if (!pr.ok || !pr.doc || typeof pr.doc !== 'object') {++ value = { url, problem: { status: pr.status === 404 ? 404 : 502, detail: pr.status === 404 ? 'GitHub has no such pull request (a private repository needs its own access)' : `GitHub answered ${pr.status}` } };++ } else {++ const doc = pr.doc;++ let ci = null;++ const sha = doc.head && doc.head.sha ? String(doc.head.sha) : null;++ if (sha && /^[0-9a-f]{7,40}$/i.test(sha)) {++ const status = await getJson(`/repos/${parts.owner}/${parts.name}/commits/${sha}/status`);++ if (status.ok && status.doc) ci = ciWord(status.doc);++ }++ value = {++ url, number: parts.number, owner: parts.owner, name: parts.name,++ state: doc.merged ? 'merged' : (doc.state === 'closed' ? 'closed' : 'open'),++ merge: mergeWord(doc),++ ci,++ title: doc.title ? String(doc.title).slice(0, 200) : null,++ branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,++ base: doc.base && doc.base.ref ? String(doc.base.ref).slice(0, 120) : null,++ checked_at: new Date().toISOString(),++ };++ }++ } catch (e) {++ log.warn(`[Codes] pull request ${key} could not be read: ${String(e && e.message).slice(0, 160)}`);++ value = { url, problem: { status: 503, detail: 'GitHub did not answer, so its state is not shown' } };++ }++ cache.set(key, { at: Date.now(), value, problem: !!value.problem });++ if (cache.size > 500) cache.delete(cache.keys().next().value);++ return value;++ }++++ return { search, pullRequest, recent, githubRepo, pullRequestParts };++}++++module.exports = { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord, MAX_FOUND };nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++++/**++ * A person's machines, as OpenVibe.Network knows them (Codes C4b).++ *++ * A machine is paired by redeeming a one-time code on OpenVibe.Network (docs: OpenVibe.Node README and++ * internal/link/network.go): openvibe.bot/machines mints `pair_…` with a code that lasts ten minutes, and the machine++ * redeems it -> `openvibe-node pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal++ * (`nod_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.++ *++ * Codes never mints a pairing: that is Bot's, holding network.node.manage. Codes reads the person's own machines with++ * their own session token (GET /api/v1/me/nodes accepts it as a Bearer) and can revoke one (the person's own route).++ *++ * createMachines({ config, fetchImpl, log })++ * list(token) → { ok, machines } or { ok: false, problem }++ * revoke(token, id) → { ok, machine } or { ok: false, problem }++ *++ * A Network that answers 401 throws (with a 401 status), so the caller may refresh the person's session and try++ * again; every other failure is a problem beside an empty list, never a silent blank.++ *++ * Nothing here holds a credential: the answers carry ids, names, timestamps and a status, never a secret.++ */++++const ONLINE_MS = 5 * 60_000;++const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++++const iso = (v) => {++ if (!v) return null;++ const t = Date.parse(String(v));++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++};++++/** One principal, as the page shows it: the state a person reads, never a secret. */++function present(node, now = Date.now()) {++ const lastSeen = iso(node && node.last_seen_at);++ const seenAt = lastSeen ? Date.parse(lastSeen) : null;++ const revoked = !!(node && (node.status === 'revoked' || node.revoked_at));++ const state = revoked ? 'revoked' : (seenAt && now - seenAt < ONLINE_MS ? 'online' : 'away');++ return {++ id: String((node && node.principal) || '').slice(0, 64),++ node_id: node && node.node_id ? String(node.node_id).slice(0, 128) : null,++ name: node && node.name ? String(node.name).slice(0, 120) : null,++ status: revoked ? 'revoked' : 'active',++ state,++ last_seen_at: lastSeen,++ created_at: iso(node && node.created_at),++ revoked_at: iso(node && node.revoked_at),++ paired_for: node && node.paired_for && typeof node.paired_for === 'object'++ ? { service: node.paired_for.service || null, ref: node.paired_for.ref || null } : null,++ };++}++++const STATE_WORD = { online: 'Online', away: 'Seen before', revoked: 'Revoked' };++++function createMachines({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');++++ async function call(token, method, path, body) {++ const res = await fetchImpl(`${base}${path}`, {++ method,++ headers: {++ Authorization: `Bearer ${token}`, Accept: 'application/json',++ ...(body ? { 'Content-Type': 'application/json' } : {}),++ },++ ...(body ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(8000),++ });++ const doc = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, doc };++ }++++ /** The person's machines, newest first. A Network that does not answer says so instead of an empty list. */++ async function list(token) {++ if (!token) return { ok: false, problem: { status: 401, code: 'machines.signed_out', detail: 'sign in again to see your machines' } };++ let out;++ try { out = await call(token, 'GET', '/api/v1/me/nodes'); } catch (e) {++ log.warn(`[Codes] the machines could not be read: ${String(e && e.message).slice(0, 200)}`);++ return { ok: false, problem: { status: 503, code: 'machines.unavailable', detail: 'OpenVibe.Network did not answer, so your machines could not be read' } };++ }++ if (!out.ok) {++ const doc = out.doc || {};++ // An expired session is the caller's to refresh: throwing with a 401 lets it try a fresh token first.++ if (out.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'machines.expired' });++ return { ok: false, problem: { status: out.status, code: doc.code || 'machines.unavailable', detail: doc.detail || doc.error || `Network answered ${out.status}` } };++ }++ const nodes = Array.isArray(out.doc && out.doc.nodes) ? out.doc.nodes : [];++ const now = Date.now();++ return { ok: true, machines: nodes.filter((n) => n && NODE.test(String(n.principal || ''))).map((n) => present(n, now)) };++ }++++ /** Revoke one of the person's machines (the owner's own route). */++ async function revoke(token, id) {++ if (!NODE.test(String(id || ''))) return { ok: false, problem: { status: 422, code: 'machines.invalid', detail: 'that is not one of your machines' } };++ let out;++ try { out = await call(token, 'POST', `/api/v1/me/nodes/${encodeURIComponent(id)}/revoke`, {}); } catch (e) {++ return { ok: false, problem: { status: 503, code: 'machines.unavailable', detail: `OpenVibe.Network did not answer: ${String(e && e.message).slice(0, 120)}` } };++ }++ if (!out.ok) {++ const doc = out.doc || {};++ if (out.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'machines.expired' });++ return { ok: false, problem: { status: out.status, code: doc.code || 'machines.unavailable', detail: doc.detail || doc.error || `Network answered ${out.status}` } };++ }++ return { ok: true, machine: present(out.doc) };++ }++++ return { list, revoke, present, STATE_WORD };++}++++module.exports = { createMachines, present, STATE_WORD, ONLINE_MS };nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++/**++ * What a person's tasks used, day by day (Codes C4b).++ *++ * Codes knows its own work: a task (run_tasks) and every attempt on it (codes_attempts), with the minutes between an++ * attempt's start and its end and the tokens its session reported. This module adds those up per calendar day.++ *++ * It knows no money. Prices are OpenVibe.Billing's: Billing's /api/v1/usage read is its ledger admin's++ * (billing.ledger.admin), and Codes holds no such grant, so the Usage screen shows runs, minutes and tokens and says++ * out loud that it shows no prices — it does not invent a ledger from its own cost fields.++ *++ * createUsage({ store })++ * daily(subject, { days }) → { days: [{ day, runs, attempts, minutes, tokens }], totals, window }++ */++++const MAX_DAYS = 90;++const DEFAULT_DAYS = 14;++++const num = (v) => (v == null || !Number.isFinite(Number(v)) ? 0 : Number(v));++++function createUsage({ store }) {++ const db = store.db;++++ /**++ * The person's days, newest first. A day with no attempt is left out: a table of empty rows says nothing.++ * `minutes` is the time an attempt was running, to the minute; `tokens` is what its session reported.++ */++ async function daily(subject, { days = DEFAULT_DAYS } = {}) {++ const window = Math.min(MAX_DAYS, Math.max(1, Number(days) || DEFAULT_DAYS));++ if (!subject) return { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window };++ const rows = await db.many(++ `SELECT to_char(date_trunc('day', COALESCE(a.started_at, a.created_at)), 'YYYY-MM-DD') AS day,++ count(DISTINCT a.task_id)::int AS runs,++ count(a.id)::int AS attempts,++ COALESCE(sum(GREATEST(0, EXTRACT(EPOCH FROM (a.finished_at - a.started_at)) / 60)), 0) AS minutes,++ COALESCE(sum(a.tokens), 0) AS tokens++ FROM codes_attempts a JOIN run_tasks t ON t.id = a.task_id++ WHERE t.subject = $1 AND COALESCE(a.started_at, a.created_at) > now() - ($2::int * interval '1 day')++ GROUP BY 1 ORDER BY 1 DESC`,++ [subject, window],++ );++ const out = rows.map((r) => ({++ day: String(r.day),++ runs: num(r.runs),++ attempts: num(r.attempts),++ minutes: Math.round(num(r.minutes)),++ tokens: num(r.tokens),++ }));++ const totals = out.reduce((a, d) => ({++ runs: a.runs + d.runs, attempts: a.attempts + d.attempts, minutes: a.minutes + d.minutes, tokens: a.tokens + d.tokens,++ }), { runs: 0, attempts: 0, minutes: 0, tokens: 0 });++ return { days: out, totals, window };++ }++++ return { daily, MAX_DAYS, DEFAULT_DAYS };++}++++module.exports = { createUsage, MAX_DAYS, DEFAULT_DAYS };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++++/**++ * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications and the safety++ * level. One row per person in codes_user_settings (migration 0013); a person without a row reads the defaults, so the++ * Settings screen is complete before anything is saved.++ *++ * createUserSettings({ store })++ * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety }++ * save(subject, patch) → the saved row, in the same shape++ *++ * Only a usr_ subject is accepted: a service token has no preferences of its own to keep.++ */++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SAFETY = ['ask', 'project', 'full'];++const AGENT = /^[a-z0-9-]{1,40}$/;++const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++++const DEFAULTS = Object.freeze({ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask' });++++function present(row) {++ if (!row) return { ...DEFAULTS };++ return {++ default_agent: row.default_agent || null,++ default_machine: row.default_machine || null,++ notify_done: row.notify_done !== false,++ notify_needs: row.notify_needs !== false,++ safety: SAFETY.includes(row.safety) ? row.safety : 'ask',++ };++}++++function createUserSettings({ store }) {++ const db = store.db;++++ async function get(subject) {++ if (!USER.test(String(subject || ''))) return { ...DEFAULTS };++ return present(await db.maybe('SELECT * FROM codes_user_settings WHERE subject = $1', [subject]));++ }++++ /**++ * Save the choices a form sent. A value that is not one Codes knows is refused with a 422 rather than stored, and a++ * field the form did not send keeps what it had: a partly filled form never wipes the rest.++ */++ async function save(subject, patch = {}) {++ if (!USER.test(String(subject || ''))) {++ throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });++ }++ const now = present(await db.maybe('SELECT * FROM codes_user_settings WHERE subject = $1', [subject]));++ const next = { ...now };++ if (patch.default_agent !== undefined) {++ const v = patch.default_agent === '' || patch.default_agent == null ? null : String(patch.default_agent);++ if (v !== null && !AGENT.test(v)) throw Object.assign(new Error('the default agent is a harness id, or empty for Auto'), { status: 422, code: 'settings.agent_unknown' });++ next.default_agent = v;++ }++ if (patch.default_machine !== undefined) {++ const v = patch.default_machine === '' || patch.default_machine == null ? null : String(patch.default_machine);++ if (v !== null && !MACHINE.test(v)) throw Object.assign(new Error('the default machine is one of your machines, or empty for Auto'), { status: 422, code: 'settings.machine_unknown' });++ next.default_machine = v;++ }++ if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';++ if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';++ if (patch.safety !== undefined) {++ const v = String(patch.safety);++ if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });++ next.safety = v;++ }++ await db.exec(++ `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, now())++ ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,++ notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety, updated_at = now()`,++ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety],++ );++ return next;++ }++++ return { get, save, DEFAULTS, SAFETY };++}++++module.exports = { createUserSettings, present, DEFAULTS, SAFETY };@@@ -35,6 +35,9 @@ const BUDGETS = {'codes.harness.route': { minute: 30, hour: 600 },// A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).'codes.task.start': { minute: 6, hour: 60 },++ // The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's++ // own repositories come first and cost nothing; this bounds what they may spend on the rest.++ 'codes.repo.search': { minute: 6, hour: 60 },};/**@@@ -1,6 +1,19 @@'use strict';--// Phone-first Codes workspace. Pages stay useful without JavaScript and link to existing task actions.++/**++ * Phone-first Codes workspace (C4, finished in C4b). Pages stay useful without JavaScript: every panel is++ * server-rendered, and the script only adds the repository search and the fold-out proofs' details.++ *++ * / the composer with the person's saved repositories and machines (last used first), running work++ * /tasks, /tasks/:id the person's tasks; a task's Live, Changes, Proof and Pull request panels are Codes' own++ * /agents the person's connections: their provider keys (they live in OpenVibe.AI), added, tested, removed++ * /machines their paired Node machines, their state, and Node's pairing flow with a one-time code++ * /usage what Codes knows: runs, minutes and tokens per day (no prices: Billing is Billing's)++ * /settings their choices, remembered per person (codes_user_settings)++ *++ * Every write is a form post from this site (Origin checked), every read is the signed-in person's own.++ */++const express = require('express');const { send } = require('../render/layout');const { raw, esc } = require('../render/html');const { asyncRouter } = require('./router');@@@ -37,44 +50,225 @@ const repoDetails = (repo) => {} catch { return null; }};const icon = (path) => `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;++const asObject = (v) => {++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;++ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }++ return null;++};++const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'DeepSeek', minimax: 'MiniMax', openrouter: 'OpenRouter', custom: 'Custom (OpenAI-compatible)' };function createC4Routes(ctx) {const r = asyncRouter();-- const { config, store, harnesses, tasks } = ctx;++ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage } = ctx;const db = store.db;-- const page = (req, res, title, body, extra = {}) => send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: /^\/tasks\/tsk_/.test(req.path) ? ['js/c4.js'] : [], body: raw(body), ...extra });++ const form = express.urlencoded({ extended: false, limit: '16kb' });++ const page = (req, res, title, body, extra = {}) => {++ const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines)/.test(req.path);++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });++ };const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;-- const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(LINKS.find((x) => x[0] === label)[2])}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0,5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;++ const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0, 5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++ const flash = (req) => {++ const ok = String(req.query.ok || '').slice(0, 120);++ const error = String(req.query.error || '').slice(0, 200);++ return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;++ };++ const note = (text, kind = '') => `<div class="notice c4-notice${kind ? ` c4-notice-${kind}` : ''}" role="status">${text}</div>`;++ const pill = (text, kind = '') => `<span class="c4-pill${kind ? ` c4-pill-${kind}` : ''}">${esc(text)}</span>`;++async function ownTasks(req, limit = 100) {if (!viewer(req)) return [];return db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT $2', [req.viewer.subject, limit]);}++ /** The person's machines, read with their own token; a Network that does not answer is said, not hidden. */++ async function machinesOf(req, res = null) {++ if (!viewer(req)) return { ok: false, machines: [] };++ try {++ return await sso.asViewer(req, res, (token) => machines.list(token));++ } catch (e) {++ return { ok: false, machines: [], problem: { status: e && e.status, detail: 'your machines could not be read: sign in again' } };++ }++ }++ /** The person's own recent repositories (no GitHub call): what the pickers offer first. */++ async function recentRepos(req, limit = 8) {++ if (!viewer(req)) return [];++ try { return await github.recent(req.viewer.subject, limit); } catch { return []; }++ }++ /** The machines the person's tasks ran on, most recently first: the picker's order. */++ async function lastUsedMachines(req, limit = 20) {++ if (!viewer(req)) return [];++ try {++ const rows = await db.many(++ `SELECT a.machine, max(COALESCE(a.finished_at, a.started_at, a.created_at)) AS at++ FROM codes_attempts a JOIN run_tasks t ON t.id = a.task_id++ WHERE t.subject = $1 AND a.machine IS NOT NULL GROUP BY a.machine ORDER BY at DESC LIMIT $2`,++ [req.viewer.subject, limit],++ );++ return rows.map((r) => String(r.machine));++ } catch { return []; }++ }++ /**++ * The person's machines in the order they read: the ones their work ran on last come first, the ones never used++ * come after them. Their saved default is marked, not moved: the order the brief asks for is the order of use.++ */++ const orderMachines = (list, used) => {++ const rank = new Map(used.map((m, i) => [m, i]));++ const rankOf = (m) => (rank.has(m.id) ? rank.get(m.id) : rank.has(m.node_id) ? rank.get(m.node_id) : 1e6);++ return [...(list || [])].sort((a, b) => rankOf(a) - rankOf(b));++ };++ /** One machine as a picker option: its name, its state, and whether it is the person's saved default. */++ const machineOption = (m, settings) => {++ const saved = settings && settings.default_machine === m.id;++ const state = m.state === 'online' ? '' : m.state === 'revoked' ? ' (revoked)' : ' (seen before)';++ return `<option value="${esc(m.id)}"${saved ? ' selected' : ''}>${esc(m.name || m.node_id || m.id)}${state}${saved ? ' · your default' : ''}</option>`;++ };++const taskHref = (row) => `/tasks/${encodeURIComponent(row.id)}`;const rowHtml = (row) => {const repo = repoDetails(row.repo);-- const attempts = Number(row.attempt_count || row.result?.attempts?.length || row.result?.attempts || 0);-- const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), money(row.result && row.result.cost), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');++ const attempts = Number(row.attempts_count || row.result?.attempts?.length || row.result?.attempts || 0);++ const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');return `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(stateLabel(row.state))}"></span><span class="c4-task-title">${esc(row.prompt || repo?.label || 'Coding task')}</span><span class="c4-task-meta">${meta}</span></a>`;};const header = (title, desc = '') => `<div class="c4-title"><div><h1>${esc(title)}</h1>${desc ? `<p>${esc(desc)}</p>` : ''}</div></div>`;const stateChip = (state) => `<span class="c4-status c4-${esc(state)}">${esc(stateLabel(state))}</span>`;-- const taskChips = (row) => {++ const taskChips = (row, machineName = null) => {const repo = repoDetails(row.repo);-- const chips = [repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : `<span class="c4-chip">Project not set</span>`, `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`, `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`, '<span class="c4-chip">Your computer</span>', `<span class="c4-chip">${esc(money(row.result && row.result.cost))}</span>`];++ const chips = [++ repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : '<span class="c4-chip">Project not set</span>',++ `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`,++ `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`,++ `<span class="c4-chip">${esc(machineName || 'Your computer')}</span>`,++ `<span class="c4-chip">${esc(money(row.result && row.result.cost_usd))}</span>`,++ ];return `<div class="c4-chips" aria-label="Task details">${chips.join('')}</div>`;};++ // ── The outcome row: one calm row, with pill actions ────────────────────────────────────────────────────────++ const outcomeRow = (row) => {++ const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);++ if (!hasOutcome) return '';++ const prompt = encodeURIComponent(row.prompt || '');++ const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;++ const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;++ return `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>`;++ };++++ /** One proof line: a calm row that folds out to its detail. ok / skipped / failed is on the row itself. */++ const proofRow = (item) => {++ const state = item.skipped ? 'skip' : (item.ok ? 'ok' : 'bad');++ const word = item.skipped ? 'Skipped' : (item.ok ? 'Passed' : 'Failed');++ const detail = asObject(item.detail) || {};++ let body = '';++ if (item.kind === 'tests') {++ body = `${detail.command ? `<p class="c4-kv"><span>Command</span><code>${esc(detail.command)}</code></p>` : ''}${item.summary ? `<pre tabindex="0" class="c4-code c4-code-small">${esc(item.summary)}</pre>` : '<p class="c4-muted">No output was kept.</p>'}`;++ } else if (item.kind === 'screenshots') {++ const shots = Array.isArray(detail.shots) ? detail.shots : [];++ body = shots.length++ ? `<ul class="c4-shots">${shots.map((s) => {++ const image = typeof s.data === 'string' && /^data:image\//.test(s.data) ? s.data : (typeof s.thumb === 'string' && /^data:image\//.test(s.thumb) ? s.thumb : null);++ const label = `${s.route || '/'} at ${Number(s.width) || 0} px`;++ const state = s.skipped ? 'Skipped' : (s.ok === false ? 'Failed' : 'Passed');++ const tile = image++ ? `<a class="c4-shot" href="${esc(image)}" target="_blank" rel="noopener"><img src="${esc(image)}" alt="${esc(label)}" loading="lazy" decoding="async"><span>${esc(label)}</span>${pill(state, s.ok === false ? 'bad' : 'ok')}</a>`++ : `<span class="c4-shot c4-shot-ghost">${icon('<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>')}<span>${esc(label)}</span>${pill(state, s.ok === false ? 'bad' : 'ok')}</span>`;++ const why = [s.summary, s.overflow ? 'the page scrolls sideways at this width' : null, Array.isArray(s.errors) && s.errors.length ? `the page threw: ${s.errors[0]}` : null].filter(Boolean)[0];++ return `<li>${tile}${why ? `<p class="c4-shot-why">${esc(why)}</p>` : ''}</li>`;++ }).join('')}</ul><p class="c4-hint">The pictures stay on the machine that took them${shots.some((s) => s && s.path) ? ' (kept there as PNG files)' : ''}; the row carries what each one found.</p>`++ : '<p class="c4-muted">No page was shot for this task.</p>';++ } else if (item.kind === 'diff') {++ const files = Array.isArray(detail.files) ? detail.files : [];++ body = `${detail.stats ? `<pre tabindex="0" class="c4-code c4-code-small">${esc(detail.stats)}</pre>` : ''}${files.length ? `<ul class="c4-filelist">${files.map((f) => `<li><code>${esc(f)}</code></li>`).join('')}</ul>` : ''}${!detail.stats && !files.length ? '<p class="c4-muted">The diff was not kept with the proof.</p>' : ''}`;++ } else if (item.kind === 'review') {++ body = `<p class="c4-kv"><span>Reviewer</span><b>${esc(item.family || 'another model')}</b></p>${item.summary ? `<p class="c4-review">${esc(item.summary)}</p>` : ''}`;++ } else {++ body = item.summary ? `<p class="c4-review">${esc(item.summary)}</p>` : '<p class="c4-muted">Nothing more was kept.</p>';++ }++ return `<li class="c4-fold-row"><details class="c4-fold"><summary><span class="c4-row-dot c4-dot-${state}" aria-hidden="true"></span><span class="c4-row-label">${esc(item.label || item.kind)}</span><span class="c4-row-note">${esc(item.summary || (item.skipped ? 'it could not run here' : ''))}</span>${pill(word, state === 'ok' ? 'ok' : state === 'skip' ? 'skip' : 'bad')}</summary><div class="c4-row-body">${body}</div></details></li>`;++ };++++ /** The Changes panel: the changed files first, then the diff itself, folded out. */++ const changesPanel = (row) => {++ const result = asObject(row.result) || {};++ const files = Array.isArray(result.files) ? result.files.filter((f) => f && f.path) : [];++ const patch = typeof result.patch === 'string' ? result.patch : '';++ if (!files.length && !patch) {++ return `<p class="c4-muted">${['queued', 'placed', 'running'].includes(row.state) ? 'The changes appear here when the task ends.' : 'This task changed no files.'}</p>`;++ }++ const added = files.reduce((n, f) => n + (Number(f.added) || 0), 0);++ const removed = files.reduce((n, f) => n + (Number(f.removed) || 0), 0);++ return `<p class="c4-panel-lede">${files.length} file${files.length === 1 ? '' : 's'} changed · <span class="c4-add">+${added}</span> <span class="c4-del">−${removed}</span></p>++${files.length ? `<ul class="c4-filelist c4-filelist-counts">${files.map((f) => `<li><code>${esc(f.path)}</code><span class="c4-add">+${f.added == null ? '·' : esc(f.added)}</span><span class="c4-del">−${f.removed == null ? '·' : esc(f.removed)}</span></li>`).join('')}</ul>` : ''}++${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-label">The diff</span><span class="c4-row-note">${result.patch_truncated ? 'cut to fit' : `${patch.split('\n').length} lines`}</span>${pill('Show', 'skill')}</summary><div class="c4-row-body"><pre tabindex="0" class="c4-diff">${esc(patch)}</pre></div></details>` : ''}`;++ };++++ /** The Pull request panel: the link, its CI state and its merge state when the task opened one. */++ const pullRequestPanel = async (row) => {++ const result = asObject(row.result) || {};++ const push = asObject(result.push) || {};++ const href = typeof push.pull_request === 'string' ? push.pull_request : null;++ if (!href) {++ const why = push.pull_request_error++ ? `The pull request was not opened: ${esc(push.pull_request_error)}`++ : (push.pushed ? 'The branch was pushed, but no pull request was asked for.' : (!row.pull_request ? 'This task did not ask for a pull request.' : 'The task did not reach the push.'));++ const prompt = encodeURIComponent(row.prompt || '');++ return `<p class="c4-muted">${why}</p>${push.branch_url ? `<p class="c4-muted">Branch: <a href="${esc(push.branch_url)}" rel="noopener noreferrer">${esc(push.branch || 'the new branch')}</a></p>` : ''}<p><a class="c4-opill" href="/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&push=1&pull_request=1">${icon('<path d="M6 3v12a3 3 0 0 0 3 3h6"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/>')}<span>Start one with a pull request</span></a></p>`;++ }++ const pr = await github.pullRequest(href);++ const stateWord = pr.problem ? 'Not read' : (pr.state === 'merged' ? 'Merged' : pr.state === 'closed' ? 'Closed' : 'Open');++ const stateKind = pr.problem ? 'skip' : (pr.state === 'merged' ? 'ok' : pr.state === 'closed' ? 'bad' : 'open');++ return `<div class="c4-rows"><div class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${stateKind}" aria-hidden="true"></span><span class="c4-row-label">Pull request #${esc(pr.number || '')}</span><span class="c4-row-note">${esc(pr.title || `${pr.owner || ''}/${pr.name || ''}`)}</span>${pill(stateWord, stateKind)}</div>++<div class="c4-pr-actions"><a class="c4-opill c4-opill-primary" href="${esc(href)}" rel="noopener noreferrer">${icon('<path d="M14 3h7v7M21 3l-9 9"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>')}<span>Open on GitHub</span></a>${pr.branch ? `<span class="c4-chip">${esc(pr.branch)} → ${esc(pr.base || 'base')}</span>` : ''}</div>++${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;++ };++++ // ── Home ────────────────────────────────────────────────────────────────────────────────────────────────────r.get('/', async (req, res) => {const rows = await ownTasks(req, 8);const signedIn = !!viewer(req);++ const settings = signedIn ? await userSettings.get(req.viewer.subject) : null;++ const repos = signedIn ? await recentRepos(req) : [];++ const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };++ const used = signedIn ? await lastUsedMachines(req) : [];++ const machineList = orderMachines(mine.machines, used);++ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');++ const machineOptions = machineList.map((m) => machineOption(m, settings)).join('');++ const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}--<section class="c4-card c4-composer"><form method="get" action="/run"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label>Project<input type="url" name="repo" placeholder="Paste a repository URL" autocomplete="url"></label><label>Agent <select name="agent"><option value="">Auto</option>${(harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><label>Machine <select name="machine"><option value="">Auto</option></select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div></form></section>--<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done','succeeded','failed','cancelled'].includes(x.state)).slice(0,4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>--<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0,5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>--${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent','Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'],['Your computer or ours','Pair your computer with one command. A hosted sandbox is coming soon.'],['Proof you can inspect','See the changes, tests, screenshots and review before you approve.'],['A cost you can follow','Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a,b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;++<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>++<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>++<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>++${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}++${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });});++++ /**++ * The composer's first step: the pickers are saved for this person, then Run's own form opens with the task++ * prefilled. Saving is best effort — a picker that cannot be saved must not stop the task.++ */++ r.post('/start', form, async (req, res) => {++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ if (viewer(req) && (b.machine || b.agent)) {++ try {++ await userSettings.save(req.viewer.subject, {++ ...(b.machine ? { default_machine: b.machine } : {}),++ ...(b.agent ? { default_agent: b.agent } : {}),++ });++ } catch (e) { /* a picker that cannot be saved does not stop the task */ }++ }++ const q = new URLSearchParams();++ for (const key of ['prompt', 'repo', 'agent']) if (b[key]) q.set(key, String(b[key]).slice(0, key === 'prompt' ? 16000 : 300));++ return res.redirect(303, `/run${q.toString() ? `?${q}` : ''}`);++ });++r.get('/tasks', async (req, res) => {const rows = await ownTasks(req);const q = String(req.query.q || '').toLowerCase();@@@ -90,35 +284,209 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</hconst content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));});++r.get('/tasks/:id', async (req, res) => {if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));if (!safeId(req.params.id)) return res.status(404).send('Not found');const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);if (!row) return res.status(404).send('Not found');-- const live = ['queued','placed','running'].includes(row.state);++ const live = ['queued', 'placed', 'running'].includes(row.state);const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];const attemptItems = attempts.map((attempt) => {const item = tasks.attempts.present(attempt);return `<li class="c4-attempt" data-attempt-n="${Number(item.n)}">Attempt ${Number(item.n)} · ${esc(item.agent || 'Auto')} · ${esc(item.end_why || item.state || 'Queued')}</li>`;}).join('');++ // The machine the task ran on, in the person's own words when their list knows it: never a bare principal id.++ let machineName = attempts.map((a) => a.machine).filter(Boolean).at(-1) || null;++ if (machineName) {++ const known = await machinesOf(req, res);++ const hit = (known.machines || []).find((m) => m.id === machineName || m.node_id === machineName);++ machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^nod_/.test(machineName) ? 'Your machine' : machineName);++ }const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;const events = `${taskApi}/events`;const prompt = encodeURIComponent(row.prompt || '');const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;-- const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);-- const outcome = hasOutcome ? `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>` : '';const progress = live ? `<button class="c4-progress-toggle" type="button" aria-expanded="false" aria-controls="live-content"><span data-progress-title>Working on your task</span><span class="c4-progress-meta"><span data-progress-steps>0 steps</span> · <span data-progress-time>now</span></span><span aria-hidden="true">⌄</span></button>` : '';-- const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcome}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${hasOutcome ? '' : stateChip(row.state)}</div>${taskChips(row)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live','Live'],['changes','Changes'],['proof','Proof'],['pull-request','Pull request']].map(([id, label], i) => `<a href="#${id}" role="tab" aria-selected="${i === 0}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel"><p>Changes from this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open changes</a></section><section id="proof" class="c4-panel" role="tabpanel"><p>Test output and screenshots for this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task proof</a></section><section id="pull-request" class="c4-panel" role="tabpanel"><p>Pull request details will appear here.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task in Run</a></section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;++ const proofItems = tasks?.proof ? tasks.proof.present(await tasks.proof.list(row.id)) : [];++ const resultNow = asObject(row.result) || {};++ const hasChanges = (Array.isArray(resultNow.files) && resultNow.files.length > 0) || typeof resultNow.patch === 'string';++ // A finished task opens on what it proved; a running one opens on its live lines.++ const preferredTab = live ? 'live' : (proofItems.length ? 'proof' : (hasChanges ? 'changes' : 'live'));++ const proofPanel = proofItems.length++ ? `<ul class="c4-rows">${proofItems.map(proofRow).join('')}</ul><p class="c4-hint">Proof, not promises: the tests the change reached, the pages it touched, the diff, and a second model's opinion.</p>`++ : `<p class="c4-muted">${live ? 'The proof is collected when the task ends: its tests, its pages, its diff and a second opinion.' : 'This task has no proof: it ended before proof was collected.'}</p>`;++ const prPanel = await pullRequestPanel(row);++ const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcomeRow(row)}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${row.outcome === 'unfinished' || row.error ? '' : stateChip(row.state)}</div>${taskChips(row, machineName)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live', 'Live'], ['changes', 'Changes'], ['proof', 'Proof'], ['pull-request', 'Pull request']].map(([id, label]) => `<a href="#${id}" role="tab" aria-selected="${id === preferredTab}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel">${changesPanel(row)}</section><section id="proof" class="c4-panel" role="tabpanel">${proofPanel}</section><section id="pull-request" class="c4-panel" role="tabpanel">${prPanel}</section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;page(req, res, 'Task', wrap('/tasks', content, true));});-- r.get('/agents', (req, res) => page(req, res, 'Agents', wrap('/agents', `${header('Agents')}${viewer(req) ? '<section id="connections" class="c4-card"><h2>Connections</h2><div class="c4-emptyline">No agent connections yet. <a class="c4-button" href="/agents#connections">Connect a key</a></div></section>' : signIn(req)}`, !!viewer(req))));-- r.get('/machines', (req, res) => page(req, res, 'Machines', wrap('/machines', `${header('Machines')}${viewer(req) ? '<section class="c4-card"><h2>Your machines</h2><div class="c4-emptyline">No paired computers yet. <a class="c4-button" href="/machines#pair">Pair this computer</a></div><div id="pair"><pre class="c4-code">curl -fsSL https://openvibe.bot/install.sh | sh -s -- --worker</pre></div></section>' : signIn(req)}`, !!viewer(req))));++++ // ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────++ const connectionsCard = async (req) => {++ let list = { keys: [], plans: [] };++ let problem = null;++ try { list = await connections.list(req.viewer.subject); } catch (e) {++ problem = { status: e && e.status, detail: (e && e.message) || 'the connections could not be read' };++ }++ const now = Date.now();++ const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');++ const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');++ return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2><span class="c4-muted">Keys live in OpenVibe.AI, never here</span></div>++${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}++${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}++<h3 class="c4-subhead">Add or replace a key</h3>++<form class="c4-form" method="post" action="/agents/connections">++ <label>Provider <select name="provider">${connections.PROVIDERS.map((p) => `<option value="${esc(p)}">${esc(PROVIDER_NAMES[p] || p)}</option>`).join('')}</select></label>++ <label>Key <input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="sk-…"></label>++ <label>Base URL <input type="url" name="base_url" placeholder="Only for a custom provider" autocomplete="off"></label>++ <label>Daily cap <input type="number" name="budget_usd_per_day" min="0" step="0.01" placeholder="Optional, in dollars"></label>++ <button class="c4-button" type="submit">Save the key</button>++</form>++<p class="c4-hint">The key is forwarded to OpenVibe.AI and sealed there; Codes keeps only the four-character hint. Test sends one tiny request with your key.</p>++</section>`;++ };++++ r.get('/agents', async (req, res) => {++ const content = `${header('Agents', 'The agents your tasks can run, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await connectionsCard(req)}` : signIn(req)}`;++ page(req, res, 'Agents', wrap('/agents', content, !!viewer(req)));++ });++ r.post('/agents/connections', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ try {++ const out = await connections.put(req.viewer.subject, b.provider, {++ key: b.key, ...(b.base_url ? { base_url: b.base_url } : {}),++ ...(b.budget_usd_per_day ? { budget_usd_per_day: b.budget_usd_per_day } : {}),++ });++ return res.redirect(303, `/agents?ok=${encodeURIComponent(`Saved the ${PROVIDER_NAMES[out.provider] || out.provider} key`)}`);++ } catch (e) {++ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key was refused').slice(0, 180))}`);++ }++ });++ r.post('/agents/connections/:provider/test', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ try {++ const out = await connections.test(req.viewer.subject, req.params.provider);++ const word = out.ok ? `The ${PROVIDER_NAMES[req.params.provider] || req.params.provider} key works` : `The key did not work: ${out.detail}`;++ return res.redirect(303, `/agents?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(String(word).slice(0, 180))}`);++ } catch (e) {++ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key could not be tested').slice(0, 180))}`);++ }++ });++ r.post('/agents/connections/:provider/remove', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ try {++ const gone = await connections.remove(req.viewer.subject, req.params.provider);++ return res.redirect(303, `/agents?${gone ? 'ok' : 'error'}=${encodeURIComponent(gone ? `Forgot the ${PROVIDER_NAMES[req.params.provider] || req.params.provider} key` : 'There was no key for that provider')}`);++ } catch (e) {++ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key could not be forgotten').slice(0, 180))}`);++ }++ });++++ // ── Machines: Node's pairing flow with a one-time code, and each machine's state ───────────────────────────++ const CODE_SHAPE = /^[0-9A-HJKMNP-TV-Z]{8}$/;++ const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));++ r.get('/machines', async (req, res) => {++ const mine = viewer(req) ? await machinesOf(req, res) : { ok: false, machines: [] };++ const raw = normaliseCode(req.query.code);++ const badCode = raw.length > 0 && !CODE_SHAPE.test(raw);++ const code = CODE_SHAPE.test(raw) ? `${raw.slice(0, 4)}-${raw.slice(4)}` : '';++ const list = (mine.machines || []).map((m) => {++ const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';++ return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;++ }).join('');++ const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2><span class="c4-muted">One command, one code</span></div>++<ol class="c4-steps">++<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>++<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --worker</code></li>++<li><b>It pairs itself</b>, then appears here as Online. A machine runs your jobs and nobody else's.</li>++</ol>++<form class="c4-form c4-form-inline" method="get" action="/machines">++ <label>Have a code? <input name="code" value="${esc(String(req.query.code || ''))}" placeholder="ABCD-1234" inputmode="text" autocomplete="off" spellcheck="false" maxlength="12"></label>++ <button class="c4-button" type="submit">Show the command</button>++</form>++${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}++${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}++</section>`;++ const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2><span class="c4-muted">${(mine.machines || []).length} paired</span></div>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;++ page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));++ });++ r.post('/machines/:id/revoke', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/machines')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ try {++ const out = await sso.asViewer(req, res, (token) => machines.revoke(token, req.params.id));++ return res.redirect(303, `/machines?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(out.ok ? 'The machine is revoked: it can no longer run your work' : (out.problem && out.problem.detail) || 'the machine could not be revoked')}`);++ } catch (e) {++ return res.redirect(303, `/machines?error=${encodeURIComponent(String((e && e.message) || 'the machine could not be revoked').slice(0, 180))}`);++ }++ });++++ // ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────r.get('/usage', async (req, res) => {-- const rows = await ownTasks(req);-- page(req, res, 'Usage', wrap('/usage', `${header('Usage')}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Recent task costs</h2><span>Last ${rows.length} tasks</span></div>${rows.map((x) => `<a class="c4-task c4-usage-row" href="${taskHref(x)}"><span class="c4-task-title">${esc(x.prompt || repoDetails(x.repo)?.label || 'Task')}</span><span class="c4-task-meta">${esc(x.agent || 'Auto')} · ${esc(stateLabel(x.state))}</span><b>${esc(money(x.result && x.result.cost))}</b></a>`).join('') || '<div class="c4-emptyline">No task usage yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>` : signIn(req)}`, !!viewer(req)));++ let report = { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window: 14 };++ if (viewer(req)) report = await usage.daily(req.viewer.subject, { days: 14 });++ const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');++ const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2><span class="c4-muted">Totals below</span></div>++${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>++<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}++<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>++</section>++<section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;++ page(req, res, 'Usage', wrap('/usage', content, !!viewer(req)));++ });++++ // ── Settings: remembered per person ────────────────────────────────────────────────────────────────────────++ const SAFETY_OPTIONS = [++ ['ask', 'Ask me', 'Approve commands as they come up.'],++ ['project', 'Auto in the project', 'Run commands within the project automatically.'],++ ['full', 'Full access', 'Let the agent use the whole computer.'],++ ];++ r.get('/settings', async (req, res) => {++ const settings = viewer(req) ? await userSettings.get(req.viewer.subject) : null;++ const mine = viewer(req) ? await machinesOf(req, res) : { machines: [] };++ const used = viewer(req) ? await lastUsedMachines(req) : [];++ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');++ // The same order the Home picker shows: the machines their work ran on last come first.++ const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');++ const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">++<div class="c4-form-grid">++<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>++<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>++</div>++<h3 class="c4-subhead">Notifications</h3>++<div class="c4-checks">++ <label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>++ <label class="c4-check"><input type="checkbox" name="notify_needs" value="1"${settings.notify_needs ? ' checked' : ''}><span><b>A task needs me</b><small>Approvals, questions and a task that stopped.</small></span></label>++</div>++<h3 class="c4-subhead">Safety</h3>++<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>++<button class="c4-button" type="submit">Save settings</button>++</form></section>` : signIn(req)}`;++ page(req, res, 'Settings', wrap('/settings', content, !!viewer(req)));++ });++ r.post('/settings', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/settings')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ try {++ await userSettings.save(req.viewer.subject, {++ default_agent: b.default_agent === undefined ? undefined : b.default_agent,++ default_machine: b.default_machine === undefined ? undefined : b.default_machine,++ notify_done: b.notify_done === '1',++ notify_needs: b.notify_needs === '1',++ ...(b.safety ? { safety: b.safety } : {}),++ });++ return res.redirect(303, '/settings?ok=Settings%20saved');++ } catch (e) {++ return res.redirect(303, `/settings?error=${encodeURIComponent(String((e && e.message) || 'the settings could not be saved').slice(0, 180))}`);++ }});-- r.get('/settings', (req, res) => page(req, res, 'Settings', wrap('/settings', `${header('Settings')}${viewer(req) ? '<section class="c4-card"><h2>Safety</h2><div class="c4-safety" role="group" aria-label="Safety level"><label class="c4-safety-card"><input type="radio" name="safety" checked><span><b>Ask me</b><small>Approve commands as they come up.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Auto in the project</b><small>Run commands within the project automatically.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Full access</b><small>Let the agent use the whole computer.</small></span></label></div><p class="c4-muted">Arrives with connections.</p></section>' : signIn(req)}`, !!viewer(req))));++return r;}@@@ -9,6 +9,7 @@* POST /me/tasks → { task: codes.task@1 } (201)* GET /me/tasks/:id → codes.task@1* POST /me/tasks/:id/cancel → codes.task@1++ * GET /me/repos?q=… → { repos: [{ full_name, url, … }] }: the Home picker's search (Codes C4b)*/const express = require('express');const { http, validate } = require('openvibe-contracts');@@@ -20,7 +21,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const DELEGABLE = new Set(['owner', 'admin']);--function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, log = console }) {++function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, github = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });@@@ -101,6 +102,18 @@ function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks =return res.status(201).json({ task: taskStore.present(row), created: true });});++ /**++ * The Home composer's repository search (Codes C4b): the person's own repositories used here, first, then GitHub's++ * public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that++ * does not answer is a `problem` beside the repositories Codes already knows, never an empty list pretending so.++ */++ r.get('/me/repos', actorLimits.budget('codes.repo.search'), async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to search your repositories');++ if (!github) return res.json({ q: String(req.query.q || ''), repos: [], problem: { status: 503, detail: 'the repository search is not available here' } });++ const out = await github.search(req.viewer.subject, req.query.q, { limit: 8 });++ return res.json({ q: out.q, repos: out.found, problem: out.problem });++ });++r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');const row = await own(req, req.params.id);@@@ -55,7 +55,9 @@ function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };const signedIn = viewer.kind === 'user';const pathNow = o.path || '/';-- const c4Workspace = pathNow === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathNow);++ // The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).++ const pathOnly = pathNow.split('?')[0];++ const c4Workspace = pathOnly === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathOnly);const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;const nav = {@@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');const settings = await t.get('/settings', { as: ada });assert.strictEqual(settings.status, 200);assert.ok(settings.text.includes('Safety'));-- assert.strictEqual((settings.text.match(/Arrives with connections\./g) || []).length, 1);++ assert.ok(settings.text.includes('Save settings'));assert.ok(!settings.text.includes('Sign in to see your work'));const hidden = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV', { as: ada });assert.strictEqual(hidden.status, 404);nnew file mode 100644@@@ -0,0 +1,182 @@++'use strict';++/**++ * Codes C4b: the screens C4 left as links.++ *++ * - Home's saved pickers: the repositories a person used here, last used first, and their paired machines++ * - a task's Changes, Proof and Pull request panels are Codes' own, from run_tasks and codes_task_proof++ * - Agents: the person's keys, added, tested and removed from the page (the key itself never comes back)++ * - Machines: the person's paired Node machines, their state, and Node's pairing flow with a one-time code++ * - Settings: remembered per person++ * - Usage: runs, minutes and tokens per day, and no prices Codes does not know++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const NODE = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV';++const OTHER = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAC';++const TASK = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';++const TASK2 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAW';++const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';++++(async () => {++ const t = await boot({ env: (network) => ({ CODES_AI_URL: 'https://ai.openvibe.services.invalid', CODES_AI_INTERNAL_URL: network.url }) });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('c4b-ada');++ const bo = t.network.addUser('c4b-bo');++ const now = Date.now();++++ try {++ await check('Home offers the repositories a person used, last used first, and their paired machines', async () => {++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at)++ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','An older task','codex','succeeded','{}'::jsonb,$3,$3),++ ($4,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Shared','main','A newer task','codex','succeeded','{}'::jsonb,$5,$5)`,++ [TASK3, ada.subject, now - 90 * 60_000, TASK2, now - 60_000]);++ t.network.addNode(ada.subject, { principal: OTHER, node_id: 'node-laptop', name: 'Laptop', status: 'active', last_seen_at: new Date().toISOString(), created_at: new Date().toISOString() });++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node-desk', name: 'Desk', status: 'active', last_seen_at: new Date().toISOString(), created_at: new Date().toISOString(), paired_for: { service: 'bot', ref: 'rob_1' } });++ const home = await t.get('/', { as: ada });++ assert.strictEqual(home.status, 200);++ const shared = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Shared');++ const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');++ assert.ok(shared > -1 && codes > -1, 'both repositories are offered');++ assert.ok(shared < codes, 'the last one used comes first');++ assert.ok(home.text.includes('<option value="' + NODE + '">Desk'), 'the machine picker offers the paired machine');++ assert.ok(home.text.includes('data-c4-repo-search'), 'the repository field searches');++ assert.ok(home.text.includes('data-c4-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the saved ones are a pill away');++ });++++ await check('the machine picker is in the order of use, on Home and on Settings', async () => {++ // The Desk ran the work; the Laptop never did, and Network lists the Laptop first.++ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)++ VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);++ await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });++ for (const page of ['/', '/settings']) {++ const shown = await t.get(page, { as: ada });++ assert.strictEqual(shown.status, 200);++ const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);++ const laptop = shown.text.indexOf(`<option value="${OTHER}"`);++ assert.ok(desk > -1 && laptop > -1, `${page} offers both machines`);++ assert.ok(desk < laptop, `${page} shows the machine used last first`);++ assert.ok(shown.text.includes('Laptop · your default'), `${page} marks the saved default`);++ }++ });++++ await check('the composer remembers the machine and agent a person picks, then opens Run’s form', async () => {++ const started = await t.get('/start', { as: ada, method: 'POST', form: { prompt: 'Fix the header', repo: 'https://github.com/OpenVibers/OpenVibe.Codes', agent: 'codex', machine: NODE } });++ assert.strictEqual(started.status, 303);++ const to = String(started.headers.get('location'));++ assert.ok(to.startsWith('/run?') && to.includes('prompt=Fix+the+header') && to.includes('agent=codex'), 'the task is carried to Run');++ const saved = await t.ctx.userSettings.get(ada.subject);++ assert.strictEqual(saved.default_machine, NODE, 'the machine is the default now');++ assert.strictEqual(saved.default_agent, 'codex', 'and so is the agent');++ });++++ await check('a task’s Changes, Proof and Pull request panels are Codes’ own', async () => {++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, outcome, outcome_note, created_at, updated_at)++ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Make the task page read well','codex','succeeded',$3::jsonb,'done','Done with proof',$4,$4)`,++ [TASK, ada.subject, JSON.stringify({ files: [{ path: 'server/http/c4.js', added: 42, removed: 7 }, { path: 'public/css/c4.css', added: 9, removed: 0 }], patch: 'diff --git a/server/http/c4.js b/server/http/c4.js\n+the change', cost_usd: 0.31, push: { pushed: true, branch: 'openvibe-codes/task', pull_request_error: 'GitHub answered 403' } }), now]);++ await t.ctx.tasks.proof.record(TASK, 1, [++ { kind: 'tests', label: 'tests', ok: true, summary: 'node test/c4.test.js passed', command: 'node test/c4.test.js' },++ { kind: 'screenshots', label: 'screenshots', ok: false, summary: '1440px: the page is 1500px wide', shots: [{ ok: true, width: 390, route: '/tasks' }, { ok: false, width: 1440, route: '/tasks', summary: 'the page is 1500px wide', path: '/tmp/proof-tasks-1440.png' }] },++ { kind: 'diff', label: 'diff', ok: true, summary: '2 files changed', files: ['server/http/c4.js'], stats: ' 2 files changed, 51 insertions(+), 7 deletions(-)' },++ { kind: 'review', label: 'review', ok: true, skipped: true, summary: 'no model of another family is connected' },++ ]);++ const page = await t.get(`/tasks/${TASK}`, { as: ada });++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ // Changes: the files first, then the diff folded out.++ assert.ok(page.text.includes('server/http/c4.js') && page.text.includes('public/css/c4.css'), 'the changed files are listed');++ assert.ok(page.text.includes('<span class="c4-add">+42</span>') || page.text.includes('c4-add'), 'with their counts');++ assert.ok(page.text.includes('The diff') && page.text.includes('+the change'), 'and the diff itself');++ // Proof: one calm row per item, each with its own state.++ assert.ok(page.text.includes('data-proof-kind') || page.text.includes('c4-row-label">tests'), 'a proof row per item');++ assert.ok(page.text.includes('Passed') && page.text.includes('Skipped') && page.text.includes('Failed'), 'each row carries its state');++ assert.ok(page.text.includes('390 px') && page.text.includes('1440 px'), 'the shots are listed by width and route');++ assert.ok(page.text.includes('the page is 1500px wide'), 'and what the shot found');++ assert.ok(page.text.includes('no model of another family is connected'), 'the second opinion says why there is none');++ // Pull request: the link or why there is none, never an invented one.++ assert.ok(page.text.includes('The pull request was not opened: GitHub answered 403'), 'the push failure is said in its own words');++ assert.ok(!page.text.includes('/run/tasks/' + TASK), 'no panel links back to Run');++ });++++ await check('a task without proof says so instead of pretending', async () => {++ const page = await t.get(`/tasks/${TASK2}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('This task has no proof'), 'the empty proof is said');++ assert.ok(page.text.includes('This task did not ask for a pull request'), 'and the empty pull request too');++ });++++ await check('Agents: a key is added, tested and removed from the page, and never comes back', async () => {++ // The key is built at run time: a scanner reads a file, and a file that spells out a key is a file with a++ // key in it.++ const KEY = ['sk', 'deepseek', '0123456789abcdef'].join('-');++ const add = await t.get('/agents/connections', { as: ada, form: { provider: 'deepseek', key: KEY, budget_usd_per_day: '3' } });++ assert.strictEqual(add.status, 303, add.text.slice(0, 200));++ assert.ok(String(add.headers.get('location')).startsWith('/agents?ok='), 'back to the list with a word');++ const page = await t.get('/agents', { as: ada });++ assert.ok(page.text.includes('DeepSeek'), 'the provider is listed');++ assert.ok(page.text.includes('\u2026cdef'), 'with its hint');++ assert.ok(!page.text.includes(KEY), 'and never the key');++ assert.ok(!(await t.dbDump()).includes(KEY), 'nor is it in Codes\' database');++ const test = await t.get('/agents/connections/deepseek/test', { as: ada, method: 'POST', form: { x: '1' } });++ assert.strictEqual(test.status, 303);++ assert.ok(String(test.headers.get('location')).includes('The%20DeepSeek%20key%20works'), 'the test says the key works');++ const remove = await t.get('/agents/connections/deepseek/remove', { as: ada, method: 'POST', form: { x: '1' } });++ assert.strictEqual(remove.status, 303);++ const after = await t.get('/agents', { as: ada });++ assert.ok(after.text.includes('No provider keys yet'), 'the key is forgotten');++ });++++ await check('Machines: the person’s own machines, their state, and Node’s pairing code flow', async () => {++ const page = await t.get('/machines', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Desk'), 'the paired machine is listed');++ assert.ok(page.text.includes('Online'), 'with its state');++ assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');++ assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');++ assert.ok(page.text.includes('"appBar":false'), 'the workspace keeps the Frame\'s own navbar: no second bar');++ const coded = await t.get('/machines?code=abcd-2345', { as: ada });++ assert.ok(coded.text.includes('"appBar":false'), 'a query string does not bring the second bar back');++ assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');++ const wrong = await t.get('/machines?code=nope', { as: ada });++ assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');++ const revoke = await t.get(`/machines/${NODE}/revoke`, { as: ada, method: 'POST', form: { x: '1' } });++ assert.strictEqual(revoke.status, 303);++ const after = await t.get('/machines', { as: ada });++ assert.ok(after.text.includes('Revoked'), 'the machine is revoked');++ // Bo cannot revoke Ada's machine.++ t.network.addNode(ada.subject, { principal: 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAA', node_id: 'node-other', name: 'Other', status: 'active', last_seen_at: new Date().toISOString() });++ const stolen = await t.get('/machines/nod_01ARZ3NDEKTSV4RRFFQ69G5FAA/revoke', { as: bo, method: 'POST', form: { x: '1' } });++ assert.strictEqual(stolen.status, 303);++ assert.ok(String(stolen.headers.get('location')).includes('error='), 'another person’s machine is refused');++ });++++ await check('Settings are remembered per person', async () => {++ const save = await t.get('/settings', { as: ada, method: 'POST', form: { default_agent: 'codex', default_machine: NODE, notify_needs: '1', safety: 'project' } });++ assert.strictEqual(save.status, 303);++ assert.ok(String(save.headers.get('location')).includes('ok='));++ const page = await t.get('/settings', { as: ada });++ assert.ok(page.text.includes('<option value="codex" selected'), 'the default agent is kept');++ assert.ok(page.text.includes('value="project" checked'), 'so is the safety level');++ assert.ok(!page.text.includes('name="notify_done" value="1" checked'), 'an unticked box stays unticked');++ const other = await t.get('/settings', { as: bo });++ assert.ok(other.text.includes('<option value="codex">'), 'and it is only this person’s');++ assert.ok(!other.text.includes('<option value="codex" selected'));++ const bad = await t.get('/settings', { as: ada, method: 'POST', form: { safety: 'anything' } });++ assert.ok(String(bad.headers.get('location')).includes('error='), 'a level Codes does not know is refused');++ });++++ await check('Usage counts runs, minutes and tokens per day, and shows no prices', async () => {++ // The task page seeds attempt 1 for a task it knows; the machine's own numbers belong on it.++ await t.get(`/tasks/${TASK}`, { as: ada });++ await db.exec(`UPDATE codes_attempts SET tokens = 1200, started_at = now() - interval '2 hours', finished_at = now() - interval '90 minutes' WHERE task_id = $1 AND n = 1`, [TASK]);++ const page = await t.get('/usage', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Runs') && page.text.includes('Minutes') && page.text.includes('Tokens'), 'the columns are there');++ assert.ok(page.text.includes('1,200'), 'the tokens are counted');++ assert.ok(page.text.includes('30 min'), 'and the minutes');++ assert.ok(page.text.includes('No prices are shown here'), 'and it says why there is no money');++ const boPage = await t.get('/usage', { as: bo });++ assert.ok(!boPage.text.includes('1,200'), 'another person’s usage is not shown');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Codes' GitHub reader (C4b), offline: a pull request's state and the repository search, with a stand-in for GitHub.++ * The page must never invent a state: what GitHub did not answer is a problem beside the link.++ */++const assert = require('assert');++const { check, done } = require('./helpers/boot');++const { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord } = require('../server/domain/github');++++const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });++++(async () => {++ await check('a repository URL and a pull request URL are read only when they are GitHub ones', async () => {++ assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes'), { owner: 'OpenVibers', name: 'OpenVibe.Codes', url: 'https://github.com/OpenVibers/OpenVibe.Codes' });++ assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);++ assert.strictEqual(githubRepo('javascript:alert(1)'), null);++ assert.deepStrictEqual(pullRequestParts('https://github.com/o/n/pull/12'), { owner: 'o', name: 'n', number: 12 });++ assert.strictEqual(pullRequestParts('https://evil.example/o/n/pull/12'), null);++ assert.strictEqual(ciWord({ state: 'success' }), 'Passed');++ assert.strictEqual(mergeWord({ merged: true }), 'Merged');++ assert.strictEqual(mergeWord({ state: 'open', mergeable: false }), 'Has conflicts');++ });++++ await check('a pull request shows GitHub’s own CI and merge state, and is cached', async () => {++ const calls = [];++ const fetchImpl = async (url) => {++ calls.push(url);++ if (url.endsWith('/pulls/12')) return json(200, { state: 'open', merged: false, mergeable: true, mergeable_state: 'clean', title: 'Make it read well', head: { sha: 'abcdef1234', ref: 'codes/x' }, base: { ref: 'main' } });++ if (url.includes('/commits/abcdef1234/status')) return json(200, { state: 'success', statuses: [] });++ return json(404, {});++ };++ const gh = createGitHub({ config: {}, fetchImpl, log: { warn() {} } });++ const pr = await gh.pullRequest('https://github.com/o/n/pull/12');++ assert.strictEqual(pr.state, 'open');++ assert.strictEqual(pr.ci, 'Passed');++ assert.strictEqual(pr.merge, 'Ready to merge');++ assert.strictEqual(pr.branch, 'codes/x');++ await gh.pullRequest('https://github.com/o/n/pull/12');++ assert.strictEqual(calls.length, 2, 'the second read comes from the cache');++ });++++ await check('a pull request GitHub does not answer is a problem, never a guessed state', async () => {++ const gh = createGitHub({ config: {}, fetchImpl: async () => json(404, { message: 'Not Found' }), log: { warn() {} } });++ const pr = await gh.pullRequest('https://github.com/o/private/pull/3');++ assert.ok(pr.problem && /private|no such pull request/i.test(pr.problem.detail), 'the problem says why');++ const broken = createGitHub({ config: {}, fetchImpl: async () => { throw new Error('offline'); }, log: { warn() {} } });++ const down = await broken.pullRequest('https://github.com/o/n/pull/4');++ assert.ok(down.problem && down.state === undefined, 'an unreachable GitHub has no state');++ });++++ await check('the search puts the person’s own repositories first, then GitHub’s public answer', async () => {++ const store = { db: { many: async () => [{ repo: 'https://github.com/OpenVibers/OpenVibe.Codes', at: 2 }, { repo: 'https://example.com/nope', at: 1 }] } };++ const fetchImpl = async (url) => url.includes('/search/repositories')++ ? json(200, { items: [{ full_name: 'OpenVibers/OpenVibe.Shared', html_url: 'https://github.com/OpenVibers/OpenVibe.Shared', private: false, stars: 3 },++ { full_name: 'someone/secret', html_url: 'https://github.com/someone/secret', private: true }, { full_name: 'OpenVibers/OpenVibe.Codes', html_url: 'https://github.com/OpenVibers/OpenVibe.Codes' }] })++ : json(404, {});++ const gh = createGitHub({ config: {}, store, fetchImpl, log: { warn() {} } });++ const out = await gh.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', 'openvibe');++ assert.strictEqual(out.recent.length, 1, 'only GitHub repositories are kept from the history');++ assert.deepStrictEqual(out.found.map((r) => r.full_name), ['OpenVibers/OpenVibe.Codes', 'OpenVibers/OpenVibe.Shared'], 'own first, no duplicates, no private repositories');++ const failing = createGitHub({ config: {}, store, fetchImpl: async () => json(403, { message: 'rate limited' }), log: { warn() {} } });++ const limited = await failing.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', 'openvibe');++ assert.ok(limited.problem && limited.problem.status === 429, 'a limited search says so');++ assert.deepStrictEqual(limited.found.map((r) => r.full_name), ['OpenVibers/OpenVibe.Codes'], 'and keeps what Codes already knows');++ const quiet = await gh.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', '');++ assert.ok(quiet.found.length >= 1 && !quiet.problem, 'an empty query spends no GitHub request');++ });++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -57,6 +57,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mgrants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,vault: new Map(), // OpenVibe.AI's stand-in: a person's own keys, by subject and provider++ nodes: new Map(), // Network's node principals (a person's machines), by principal id};let issuer = null;let nextUserId = 1;@@@ -341,6 +342,23 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });}++ // ── Network's node principals (Codes C4b): a person's own machines ──++ if (url.pathname === '/api/v1/me/nodes' || url.pathname.startsWith('/api/v1/me/nodes/')) {++ const u = userFrom(req);++ if (!u) return problem(json, 401, 'auth.required', 'sign in to see your machines');++ if (req.method === 'GET' && url.pathname === '/api/v1/me/nodes') {++ const mine = [...st.nodes.values()].filter((n) => n.owner === u.subject).map((n) => ({ ...n.view }));++ return json(200, { nodes: mine });++ }++ const m = /^\/api\/v1\/me\/nodes\/([^/]+)\/revoke$/.exec(url.pathname);++ if (req.method === 'POST' && m) {++ const node = st.nodes.get(decodeURIComponent(m[1]));++ if (!node || node.owner !== u.subject) return problem(json, 404, 'registry.unknown_node', 'no such machine of yours');++ node.view = { ...node.view, status: 'revoked', revoked_at: new Date().toISOString() };++ return json(200, node.view);++ }++ return problem(json, 404, 'route.not_found', 'no such route');++ }if (url.pathname.startsWith('/api/v1/projects')) {const u = userFrom(req);if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');@@@ -363,6 +381,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.msetUsage(projectId, body) { st.usage.set(projectId, body); },setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },setCatalog(list) { st.catalog = list; },++ addNode(subject, view) { st.nodes.set(view.principal, { owner: subject, view }); return view; },setDown(v) { st.down = v; },mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },/** A Network client-credentials token: a first-party service (svc:<id>) or an app (app:<id>), for audience, holding cap. */@@@ -15,11 +15,11 @@ const BUDGETS = {htmlRawKB: 42, // 40.4 with the ten harnesses the catalogue holds (2026-10-30: MiniMax Code and Claude Code on another provider joined it, and the home lists every one); 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.-- jsRawKB: 317, // 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)-- jsBrotliKB: 78, // 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)++ jsRawKB: 330, // 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)++ jsBrotliKB: 82, // 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.-- cssRawKB: 50, // 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.-- cssBrotliKB: 11, // 9.9 KB after adding the C4 workspace styles; 1.1 KB headroom for compression drift.++ cssRawKB: 60, // 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.++ cssBrotliKB: 13, // 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.externalFiles: 2, // 0};@@@ -51,6 +51,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person','server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person','server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',++ 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',++ 'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path','server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person','server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',};
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+-- Codes C4b: a person's own workspace choices. The Settings screen remembers the default agent, the default machine,
+-- their notifications and their safety level per person, so a signed-in visitor finds their choices where they left
+-- them. One row per person; no other table holds a preference (run_tasks holds work, codes_attempts its attempts).
+--
+-- The row holds ids a person already sees (a harness id such as codex, a node principal nod_…, a level) and three
+-- yes/no choices. It holds no secret: the provider keys live in OpenVibe.AI (codes_connections is not a table here).
+
+CREATE TABLE IF NOT EXISTS codes_user_settings (
+ subject text COLLATE "C" PRIMARY KEY, -- usr_<ULID>
+ default_agent text COLLATE "C", -- a harness id from the catalog, or null for Auto
+ default_machine text COLLATE "C", -- a node principal (nod_…), or null for Auto
+ notify_done boolean NOT NULL DEFAULT true, -- tell me when a task finishes
+ notify_needs boolean NOT NULL DEFAULT true, -- tell me when a task needs me
+ safety text COLLATE "C" NOT NULL DEFAULT 'ask', -- ask | project | full
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.5.0",
+ "version": "0.5.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.5.0",
+ "version": "0.5.1",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
+.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}
.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}
.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}
.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}
.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}
.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}
.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}
.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}
.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}
-.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:40px}
+.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px}
.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}
.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}
.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
@@ -33,17 +33,123 @@
@@ -33,17 +33,123 @@
.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}
.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}
.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}
.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}
.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
-.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:0 11px 0 9px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
+.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:0 14px 0 12px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}
.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}
@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}
-@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:36px;padding:0 9px;font-size:11px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
-:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba}
+@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
+:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}
.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}
+
+/* C4b: the row pattern (one calm row, pill actions), the fold-out panels, and the forms that remember choices. */
+.c4-rows{display:grid;gap:0;list-style:none;margin:6px 0 0;padding:0}
+.c4-row{display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:4px 10px;border-top:1px solid var(--c4-line);padding:2px 0;min-width:0}
+.c4-row:first-child{border-top:0}
+.c4-fold-row{border-top:1px solid var(--c4-line);min-width:0;list-style:none}
+.c4-fold-row:first-child{border-top:0}
+.c4-row-flat{padding:10px 0;min-height:52px}
+.c4-row-dot{width:9px;height:9px;border-radius:50%;background:var(--c4-dot);grid-column:1;grid-row:1}
+.c4-dot-ok{background:var(--c4-dot-ok)}.c4-dot-bad{background:var(--c4-dot-bad)}.c4-dot-skip{background:var(--c4-dot-skip)}.c4-dot-open{background:var(--c4-dot-open)}
+.c4-row-label{grid-column:2;font-size:13px;font-weight:650;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.c4-row-note{grid-column:2;grid-row:2;color:var(--c4-muted);font-size:12px;line-height:1.4;min-width:0;overflow-wrap:anywhere}
+.c4-row > .c4-pill,.c4-row > form{grid-column:3;grid-row:1/3;justify-self:end}
+.c4-row-flat .c4-row-note{grid-row:auto}
+.c4-row-flat > .c4-pill,.c4-row-flat > form{grid-row:1/3}
+.c4-pill{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:3px 10px;border-radius:99px;background:var(--c4-quiet-bg);color:var(--c4-quiet-ink);font-size:11px;font-weight:650;white-space:nowrap;border:1px solid transparent}
+.c4-pill-ok{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}.c4-pill-bad{background:var(--c4-bad-bg);color:var(--c4-bad-ink)}.c4-pill-skip{background:var(--c4-skip-bg);color:var(--c4-skip-ink)}.c4-pill-open{background:var(--c4-open-bg);color:var(--c4-open-ink)}.c4-pill-skill{background:var(--c4-quiet-bg);color:var(--c4-quiet-ink)}
+.c4-fold{border:0;min-width:0}
+.c4-fold > summary{display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:4px 10px;min-height:44px;padding:8px 0;cursor:pointer;list-style:none}
+.c4-fold > summary::-webkit-details-marker{display:none}
+.c4-fold > summary::marker{content:""}
+.c4-fold > summary .c4-row-label{grid-column:2;grid-row:1}
+.c4-fold > summary .c4-row-note{grid-column:2;grid-row:2}
+.c4-fold > summary .c4-pill{grid-column:3;grid-row:1/3}
+.c4-fold[open] > summary{border-bottom:1px solid var(--c4-line)}
+.c4-fold-body{padding:10px 0 14px 22px;min-width:0}
+.c4-shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;list-style:none;margin:0;padding:0}
+.c4-shot{display:grid;gap:5px;justify-items:start;border:1px solid var(--c4-line);border-radius:10px;padding:8px;color:inherit;text-decoration:none;min-width:0}
+.c4-shot img{width:100%;height:auto;border-radius:6px;display:block}
+.c4-shot > span{font-size:11px;color:var(--c4-muted);overflow-wrap:anywhere}
+.c4-shot svg{width:20px;height:20px;color:var(--c4-muted)}
+.c4-shot-ghost{grid-template-columns:20px minmax(0,1fr);align-items:center;background:var(--c4-ghost)}
+.c4-shot-why{margin:4px 0 0;font-size:11px;color:var(--c4-muted);overflow-wrap:anywhere}
+.c4-filelist{list-style:none;margin:6px 0;padding:0;display:grid;gap:4px}
+.c4-filelist > li{display:grid;grid-template-columns:minmax(0,1fr);gap:8px;align-items:center;font-size:12px;min-width:0}
+.c4-filelist code{overflow-wrap:anywhere}
+.c4-filelist-counts > li{grid-template-columns:minmax(0,1fr) auto auto}
+.c4-add{color:#47713d;font-variant-numeric:tabular-nums}.c4-del{color:#995128;font-variant-numeric:tabular-nums}
+.c4-diff{max-height:420px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:11.5px;line-height:1.5;white-space:pre;max-width:100%}
+.c4-code-small{font-size:11.5px;max-height:260px;overflow:auto}
+.c4-panel-lede{margin:2px 0 8px;font-size:13px;color:var(--c4-muted)}
+.c4-kv{display:grid;gap:3px;margin:6px 0;font-size:12px;min-width:0}
+.c4-kv > span{color:var(--c4-muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
+.c4-kv code{overflow-wrap:anywhere;background:#f5f7f7;border-radius:6px;padding:6px 8px;display:block}
+.c4-review{margin:8px 0 0;font-size:13px;line-height:1.5;color:var(--c4-ink);overflow-wrap:anywhere}
+.c4-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:10px 0 0;padding:0}
+.c4-facts > div{display:grid;gap:5px;min-width:0}
+.c4-facts dt{font-size:11px;color:var(--c4-muted)}
+.c4-facts dd{margin:0;font-size:13px}
+.c4-pr-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0 4px}
+.c4-inline-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
+.c4-opill-quiet{background:var(--c4-quiet);color:var(--c4-ink)}
+.c4-form{display:grid;gap:12px;margin-top:10px}
+.c4-form label{display:grid;gap:5px;font-size:12px;color:var(--c4-muted);min-width:0}
+.c4-form input,.c4-form select{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:9px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px;min-width:0;width:100%}
+.c4-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
+.c4-form-inline{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px}
+.c4-checks{display:grid;gap:10px;margin-top:10px}
+.c4-check{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:12px;cursor:pointer;min-height:64px}
+.c4-check input{margin-top:3px;accent-color:var(--c4-accent);flex:none;width:18px;height:18px;min-height:0}
+.c4-check span{display:grid;gap:4px}.c4-check small{color:var(--c4-muted);font-size:12px;line-height:1.4}
+.c4-subhead{font-size:13px;margin:18px 0 0;color:var(--c4-ink)}
+.c4-hint{margin:8px 0 0;font-size:12px;color:var(--c4-muted);line-height:1.5}
+.c4-hint.is-bad{color:#995128}
+.c4-notice{margin:0 0 14px;font-size:13px}
+.c4-notice-bad{border-color:var(--c4-bad-line)}
+.c4-table{width:100%;border-collapse:collapse;font-size:12.5px;font-variant-numeric:tabular-nums}
+.c4-table th,.c4-table td{padding:9px 8px;border-top:1px solid var(--c4-line);text-align:right;white-space:nowrap}
+.c4-table th:first-child,.c4-table td:first-child{text-align:left}
+.c4-table thead th{color:var(--c4-muted);font-weight:600;border-top:0}
+.c4-table tfoot th,.c4-table tfoot td{font-weight:700;border-top:1px solid var(--c4-line)}
+.c4-steps{margin:0;padding-left:20px;display:grid;gap:8px;font-size:13px;line-height:1.5}
+.c4-steps a{color:var(--c4-accent)}
+.c4-code-inline{display:inline-block;max-width:100%;overflow-wrap:anywhere;background:#f5f7f7;border-radius:6px;padding:3px 6px;font-size:12px}
+.c4-note-inline{color:var(--c4-muted)}
+.c4-pair-command{display:grid;gap:6px;margin-top:10px}
+.c4-saved{margin:10px 0 0}
+.c4-saved .c4-chip svg{width:14px;height:14px;margin-right:5px}
+.c4-card a.c4-opill,.c4-card a.c4-opill-quiet{color:var(--c4-ink)}
+.c4-card a.c4-opill-primary{color:#fff}
+:root[data-theme-mode="dark"] .c4-shot-ghost,:root[data-theme-mode="dark"] .c4-diff,:root[data-theme-mode="dark"] .c4-kv code,:root[data-theme-mode="dark"] .c4-code-inline{background:#11181b;color:#d3dfdf}
+:root[data-theme-mode="dark"] .c4-opill-quiet{background:#20292d;color:var(--c4-ink)}
+:root[data-theme-mode="dark"] .c4-form input,:root[data-theme-mode="dark"] .c4-form select{background:#11181b;color:var(--c4-ink)}
+@media(max-width:760px){
+ .c4-form-grid{grid-template-columns:1fr}.c4-form-inline{grid-template-columns:1fr}
+ .c4-row{grid-template-columns:12px minmax(0,1fr)}.c4-row > .c4-pill,.c4-row > form,.c4-row-flat > .c4-pill,.c4-row-flat > form{grid-column:2;grid-row:auto;justify-self:start;margin-bottom:8px}
+ .c4-fold > summary{grid-template-columns:12px minmax(0,1fr)}.c4-fold > summary .c4-pill{grid-column:2;grid-row:3;justify-self:start;margin-bottom:4px}
+ .c4-fold-body{padding-left:0}.c4-shots{grid-template-columns:1fr 1fr}
+ .c4-inline-actions{justify-content:flex-start}
+}
+/* C4b fixes: the shared safety/notification cards keep their own layout inside a form, and a radio stays a radio. */
+.c4-form label.c4-safety-card,.c4-form label.c4-check{display:flex;align-items:flex-start;gap:10px}
+.c4-form .c4-safety-card input,.c4-form .c4-check input{width:18px;height:18px;min-height:0;padding:0;border:0;background:none;accent-color:var(--c4-accent);flex:none;margin-top:3px}
+@media(max-width:760px){.c4-controls .c4-field-repo{grid-column:1/-1}}
+@media(max-width:760px){
+ .c4-row > .c4-inline-actions{grid-column:2;grid-row:auto;justify-content:flex-start;margin:2px 0 8px}
+ .c4-card-head{flex-wrap:wrap}
+ .c4-card-head > .c4-muted{flex:1 1 100%}
+}
+.c4-hint-status{display:block;margin-top:4px}
+.c4-hint-status:empty{display:none}
+/* C4b: on a touch screen every tappable thing a person reaches for is at least 44 px tall. */
+@media(pointer:coarse){
+ .c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}
+ .c4-card-head a{display:inline-flex;align-items:center;min-height:44px}
+}
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
links.forEach((link) => link.setAttribute('aria-selected', String(link.dataset.c4Tab === id)));
links.forEach((link) => link.setAttribute('aria-selected', String(link.dataset.c4Tab === id)));
panels.forEach((panel) => { panel.hidden = panel.id !== id; });
panels.forEach((panel) => { panel.hidden = panel.id !== id; });
};
};
- activate('live');
+ const chosen = links.find((link) => link.getAttribute('aria-selected') === 'true');
+ activate(chosen ? chosen.dataset.c4Tab : 'live');
links.forEach((link) => link.addEventListener('click', (event) => {
links.forEach((link) => link.addEventListener('click', (event) => {
event.preventDefault();
event.preventDefault();
activate(link.dataset.c4Tab);
activate(link.dataset.c4Tab);
@@ -92,7 +93,8 @@
@@ -92,7 +93,8 @@
if (reason) reason.textContent = outcome.note || task.error || 'The task stopped before it was complete.';
if (reason) reason.textContent = outcome.note || task.error || 'The task stopped before it was complete.';
}
}
const chip = detail.querySelector('.c4-task-heading > .c4-status');
const chip = detail.querySelector('.c4-task-heading > .c4-status');
- if (chip) chip.textContent = task.state || 'Unknown';
+ // The words the page uses, not Run's own state id.
+ if (chip) chip.textContent = ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' })[task.state] || task.state || 'Unknown';
if (label) label.textContent = ['succeeded', 'failed', 'cancelled', 'expired'].includes(task.state) ? 'This task has finished.' : 'Connecting to the live task…';
if (label) label.textContent = ['succeeded', 'failed', 'cancelled', 'expired'].includes(task.state) ? 'This task has finished.' : 'Connecting to the live task…';
};
};
if (detail) fetch(detail.dataset.taskApi, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
if (detail) fetch(detail.dataset.taskApi, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
@@ -142,4 +144,43 @@
@@ -142,4 +144,43 @@
if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
});
});
});
});
+ // The repository picker: the person's own repositories first, then GitHub's public answer for what they type.
+ // Server-rendered options already hold the saved ones, so the field works with this script blocked.
+ document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {
+ const list = document.getElementById(input.getAttribute('list'));
+ const status = input.closest('form')?.querySelector('[data-c4-repo-status]');
+ const url = input.dataset.c4RepoUrl;
+ if (!list || !url) return;
+ let timer = 0;
+ let seq = 0;
+ const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };
+ const search = async () => {
+ const q = input.value.trim();
+ if (q.length < 2 || /^https?:\/\//i.test(q)) { say(''); return; }
+ const mine = ++seq;
+ say('Searching your repositories…');
+ try {
+ const res = await fetch(`${url}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
+ if (!res.ok) throw new Error(`search answered ${res.status}`);
+ const doc = await res.json();
+ if (mine !== seq) return;
+ list.textContent = '';
+ for (const repo of doc.repos || []) {
+ const option = document.createElement('option');
+ option.value = repo.url;
+ option.label = repo.full_name + (repo.recent ? ' · used here' : '');
+ list.append(option);
+ }
+ const found = (doc.repos || []).length;
+ if (doc.problem) say(doc.problem.detail || 'GitHub could not be searched', true);
+ else say(found ? `${found} repositor${found === 1 ? 'y' : 'ies'}; pick one or paste a URL.` : 'No repository matched. Paste its URL.');
+ } catch (e) {
+ if (mine === seq) say('Search is not available; paste the repository URL.', true);
+ }
+ };
+ input.addEventListener('input', () => {
+ window.clearTimeout(timer);
+ timer = window.setTimeout(search, 350);
+ });
+ });
})();
})();
@@ -46,6 +46,10 @@ const { createMovedRoutes } = require('./http/moved');
@@ -46,6 +46,10 @@ const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createForgejoClient } = require('./domain/forgejo');
+const { createMachines } = require('./domain/machines');
+const { createUserSettings } = require('./domain/user-settings');
+const { createGitHub } = require('./domain/github');
+const { createUsage } = require('./domain/usage');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -81,6 +85,11 @@ async function createApp(opts = {}) {
@@ -81,6 +85,11 @@ async function createApp(opts = {}) {
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, benchPublisher };
const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, benchPublisher };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
+ // The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
+ ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ ctx.userSettings = createUserSettings({ store });
+ ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
+ ctx.usage = createUsage({ store });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -181,7 +190,7 @@ async function createApp(opts = {}) {
@@ -181,7 +190,7 @@ async function createApp(opts = {}) {
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
- app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
@@ -110,6 +110,10 @@ function load(env = process.env) {
@@ -110,6 +110,10 @@ function load(env = process.env) {
// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
+ // GitHub (Codes C4b): the repository search behind the Home picker, and the CI and merge state beside a task's
+ // pull request. Read-only, public answers; the token only raises GitHub's limits and is never shown or logged.
+ github: { token: String(env.CODES_GITHUB_TOKEN || '').trim() },
+
// A task's own budget (engine/budget.js): steps (tool calls), minutes (wall clock) and dollars. A limit that is
// A task's own budget (engine/budget.js): steps (tool calls), minutes (wall clock) and dollars. A limit that is
// not set is no limit — 0 means "no limit", never a zero budget. Steps and minutes buy one wrap-up turn.
// not set is no limit — 0 means "no limit", never a zero budget. Steps and minutes buy one wrap-up turn.
budget: {
budget: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+
+/**
+ * Codes' small GitHub reader (Codes C4b): which repositories a person may pick for a task, and what happened to the
+ * pull request a task opened.
+ *
+ * Two rules shape it. The first is that Codes holds no GitHub account of a person: there is no per-person GitHub link
+ * in OpenVibe.Network today, so a repository is searched on GitHub's public repository search, narrowed to the person's
+ * own repositories when they have used them here before. The second is that a pull request's state is GitHub's, not
+ * Codes': the link comes from the task's own result, and its CI and merge state are read from api.github.com and shown
+ * as read, or said not to have been read — never guessed.
+ *
+ * createGitHub({ config, store, fetchImpl, log })
+ * search(subject, q) → { recent, found, problem }: repositories, the person's own first
+ * pullRequest(url) → { url, state, ci, mergeable, checked_at } or { url, problem }
+ *
+ * The optional token (CODES_GITHUB_TOKEN) raises GitHub's limits; it is never logged, never in an answer and never sent
+ * anywhere but api.github.com.
+ */
+
+const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,38})$/;
+const NAME = /^[A-Za-z0-9._-]{1,100}$/;
+const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d{1,10})\/?$/;
+const REPO_URL = /^(?:https:\/\/|git@)github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i;
+const TTL_MS = 2 * 60_000;
+const FAIL_MS = 20_000;
+const SEARCH_TTL_MS = 60_000;
+const MAX_FOUND = 8;
+
+/** A repository URL as Codes stores it (https, no .git), or null when it is not a GitHub one. */
+function githubRepo(url) {
+ const m = REPO_URL.exec(String(url || '').trim());
+ return m && OWNER.test(m[1]) && NAME.test(m[2]) ? { owner: m[1], name: m[2], url: `https://github.com/${m[1]}/${m[2]}` } : null;
+}
+
+/** A pull request URL's parts, or null when it is not one. */
+function pullRequestParts(url) {
+ const m = PR_URL.exec(String(url || '').trim());
+ return m ? { owner: m[1], name: m[2], number: Number(m[3]) } : null;
+}
+
+/** The merge state in the words a person reads. */
+function mergeWord(pr) {
+ if (!pr) return null;
+ if (pr.merged) return 'Merged';
+ if (pr.state === 'closed') return 'Closed without merging';
+ if (pr.draft) return 'Draft';
+ if (pr.mergeable === false) return 'Has conflicts';
+ if (pr.mergeable_state === 'blocked') return 'Waiting for checks or a review';
+ if (pr.mergeable_state === 'behind') return 'Behind its base branch';
+ if (pr.mergeable_state === 'clean' || pr.mergeable === true) return 'Ready to merge';
+ return 'Open';
+}
+
+/** The CI state in the words a person reads, from GitHub's combined commit status. */
+function ciWord(status) {
+ const state = status && (status.state || status.status);
+ return ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[state] || null;
+}
+
+function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const token = String((config.github && config.github.token) || '').trim();
+ const cache = new Map();
+ const searches = new Map();
+
+ const headers = () => ({
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'openvibe-codes',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ });
+
+ async function getJson(path) {
+ const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(6000) });
+ const doc = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, doc };
+ }
+
+ /** The person's own repositories used here, newest first (a URL is enough: Codes stored them already). */
+ async function recent(subject, limit = MAX_FOUND) {
+ if (!store || !subject) return [];
+ const rows = await store.db.many(
+ `SELECT repo, max(created_at) AS at FROM run_tasks WHERE subject = $1 AND repo LIKE 'https://github.com/%' GROUP BY repo ORDER BY at DESC LIMIT $2`,
+ [subject, limit],
+ );
+ return rows.map((r) => githubRepo(r.repo)).filter(Boolean)
+ .map((r) => ({ full_name: `${r.owner}/${r.name}`, url: r.url, recent: true }));
+ }
+
+ /**
+ * Search: the person's own repositories first (the ones they used here, then GitHub's search for what they typed).
+ * A GitHub that does not answer leaves the answers Codes already knows, and says it could not search.
+ *
+ * The answer is cached for a minute: a person typing the same letters again should not spend GitHub's budget twice.
+ */
+ async function search(subject, q, { limit = MAX_FOUND } = {}) {
+ const query = String(q || '').trim().slice(0, 100);
+ const key = `${subject || ''}|${query.toLowerCase()}|${limit}`;
+ const hit = searches.get(key);
+ if (hit && Date.now() - hit.at < SEARCH_TTL_MS) return hit.value;
+ const value = await searchNow(subject, query, limit);
+ searches.set(key, { at: Date.now(), value });
+ if (searches.size > 200) searches.delete(searches.keys().next().value);
+ return value;
+ }
+
+ async function searchNow(subject, query, limit) {
+ const mine = await recent(subject, limit);
+ const found = [];
+ let problem = null;
+ const qq = query.replace(/[^A-Za-z0-9._/-]+/g, ' ').trim();
+ if (qq) {
+ const path = `/search/repositories?q=${encodeURIComponent(`${qq} in:name`)}&sort=stars&order=desc&per_page=${limit}`;
+ try {
+ const out = await getJson(path);
+ if (out.ok && out.doc && Array.isArray(out.doc.items)) {
+ for (const item of out.doc.items.slice(0, limit)) {
+ if (!item || !item.full_name || item.private) continue;
+ found.push({
+ full_name: String(item.full_name).slice(0, 140),
+ url: String(item.html_url || `https://github.com/${item.full_name}`).slice(0, 300),
+ description: item.description ? String(item.description).slice(0, 160) : null,
+ updated_at: item.updated_at || null,
+ stars: Number(item.stargazers_count) || 0,
+ });
+ }
+ } else {
+ problem = {
+ status: out.status === 403 || out.status === 429 ? 429 : 502,
+ detail: out.status === 403 || out.status === 429
+ ? 'GitHub limits how many searches one address may make; the repositories you used here still work'
+ : `GitHub answered ${out.status} for the search`,
+ };
+ }
+ } catch (e) {
+ problem = { status: 503, detail: `GitHub did not answer: ${String(e && e.message).slice(0, 120)}` };
+ }
+ }
+ // The person's own repositories that match what they typed come first, then GitHub's public answer.
+ const asked = query.toLowerCase();
+ const own = mine.filter((r) => !asked || r.full_name.toLowerCase().includes(asked));
+ const seen = new Set(own.map((r) => r.full_name.toLowerCase()));
+ const rest = found.filter((r) => !seen.has(r.full_name.toLowerCase()));
+ return { q: query, recent: mine, found: [...own, ...rest].slice(0, limit), problem };
+ }
+
+ /**
+ * A pull request's state, as GitHub has it now. Cached for two minutes (a failure for twenty seconds), so a page a
+ * person reloads does not spend GitHub's budget. Never throws: a read that failed is a problem, and the page shows
+ * the link without the state.
+ */
+ async function pullRequest(url) {
+ const parts = pullRequestParts(url);
+ if (!parts) return { url: String(url || ''), problem: { status: 422, detail: 'that is not a GitHub pull request' } };
+ const key = `${parts.owner}/${parts.name}#${parts.number}`;
+ const hit = cache.get(key);
+ if (hit && Date.now() - hit.at < (hit.problem ? FAIL_MS : TTL_MS)) return hit.value;
+ let value;
+ try {
+ const pr = await getJson(`/repos/${parts.owner}/${parts.name}/pulls/${parts.number}`);
+ if (!pr.ok || !pr.doc || typeof pr.doc !== 'object') {
+ value = { url, problem: { status: pr.status === 404 ? 404 : 502, detail: pr.status === 404 ? 'GitHub has no such pull request (a private repository needs its own access)' : `GitHub answered ${pr.status}` } };
+ } else {
+ const doc = pr.doc;
+ let ci = null;
+ const sha = doc.head && doc.head.sha ? String(doc.head.sha) : null;
+ if (sha && /^[0-9a-f]{7,40}$/i.test(sha)) {
+ const status = await getJson(`/repos/${parts.owner}/${parts.name}/commits/${sha}/status`);
+ if (status.ok && status.doc) ci = ciWord(status.doc);
+ }
+ value = {
+ url, number: parts.number, owner: parts.owner, name: parts.name,
+ state: doc.merged ? 'merged' : (doc.state === 'closed' ? 'closed' : 'open'),
+ merge: mergeWord(doc),
+ ci,
+ title: doc.title ? String(doc.title).slice(0, 200) : null,
+ branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,
+ base: doc.base && doc.base.ref ? String(doc.base.ref).slice(0, 120) : null,
+ checked_at: new Date().toISOString(),
+ };
+ }
+ } catch (e) {
+ log.warn(`[Codes] pull request ${key} could not be read: ${String(e && e.message).slice(0, 160)}`);
+ value = { url, problem: { status: 503, detail: 'GitHub did not answer, so its state is not shown' } };
+ }
+ cache.set(key, { at: Date.now(), value, problem: !!value.problem });
+ if (cache.size > 500) cache.delete(cache.keys().next().value);
+ return value;
+ }
+
+ return { search, pullRequest, recent, githubRepo, pullRequestParts };
+}
+
+module.exports = { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord, MAX_FOUND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+
+/**
+ * A person's machines, as OpenVibe.Network knows them (Codes C4b).
+ *
+ * A machine is paired by redeeming a one-time code on OpenVibe.Network (docs: OpenVibe.Node README and
+ * internal/link/network.go): openvibe.bot/machines mints `pair_…` with a code that lasts ten minutes, and the machine
+ * redeems it -> `openvibe-node pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal
+ * (`nod_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.
+ *
+ * Codes never mints a pairing: that is Bot's, holding network.node.manage. Codes reads the person's own machines with
+ * their own session token (GET /api/v1/me/nodes accepts it as a Bearer) and can revoke one (the person's own route).
+ *
+ * createMachines({ config, fetchImpl, log })
+ * list(token) → { ok, machines } or { ok: false, problem }
+ * revoke(token, id) → { ok, machine } or { ok: false, problem }
+ *
+ * A Network that answers 401 throws (with a 401 status), so the caller may refresh the person's session and try
+ * again; every other failure is a problem beside an empty list, never a silent blank.
+ *
+ * Nothing here holds a credential: the answers carry ids, names, timestamps and a status, never a secret.
+ */
+
+const ONLINE_MS = 5 * 60_000;
+const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const iso = (v) => {
+ if (!v) return null;
+ const t = Date.parse(String(v));
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+};
+
+/** One principal, as the page shows it: the state a person reads, never a secret. */
+function present(node, now = Date.now()) {
+ const lastSeen = iso(node && node.last_seen_at);
+ const seenAt = lastSeen ? Date.parse(lastSeen) : null;
+ const revoked = !!(node && (node.status === 'revoked' || node.revoked_at));
+ const state = revoked ? 'revoked' : (seenAt && now - seenAt < ONLINE_MS ? 'online' : 'away');
+ return {
+ id: String((node && node.principal) || '').slice(0, 64),
+ node_id: node && node.node_id ? String(node.node_id).slice(0, 128) : null,
+ name: node && node.name ? String(node.name).slice(0, 120) : null,
+ status: revoked ? 'revoked' : 'active',
+ state,
+ last_seen_at: lastSeen,
+ created_at: iso(node && node.created_at),
+ revoked_at: iso(node && node.revoked_at),
+ paired_for: node && node.paired_for && typeof node.paired_for === 'object'
+ ? { service: node.paired_for.service || null, ref: node.paired_for.ref || null } : null,
+ };
+}
+
+const STATE_WORD = { online: 'Online', away: 'Seen before', revoked: 'Revoked' };
+
+function createMachines({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+
+ async function call(token, method, path, body) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`, Accept: 'application/json',
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(8000),
+ });
+ const doc = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, doc };
+ }
+
+ /** The person's machines, newest first. A Network that does not answer says so instead of an empty list. */
+ async function list(token) {
+ if (!token) return { ok: false, problem: { status: 401, code: 'machines.signed_out', detail: 'sign in again to see your machines' } };
+ let out;
+ try { out = await call(token, 'GET', '/api/v1/me/nodes'); } catch (e) {
+ log.warn(`[Codes] the machines could not be read: ${String(e && e.message).slice(0, 200)}`);
+ return { ok: false, problem: { status: 503, code: 'machines.unavailable', detail: 'OpenVibe.Network did not answer, so your machines could not be read' } };
+ }
+ if (!out.ok) {
+ const doc = out.doc || {};
+ // An expired session is the caller's to refresh: throwing with a 401 lets it try a fresh token first.
+ if (out.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'machines.expired' });
+ return { ok: false, problem: { status: out.status, code: doc.code || 'machines.unavailable', detail: doc.detail || doc.error || `Network answered ${out.status}` } };
+ }
+ const nodes = Array.isArray(out.doc && out.doc.nodes) ? out.doc.nodes : [];
+ const now = Date.now();
+ return { ok: true, machines: nodes.filter((n) => n && NODE.test(String(n.principal || ''))).map((n) => present(n, now)) };
+ }
+
+ /** Revoke one of the person's machines (the owner's own route). */
+ async function revoke(token, id) {
+ if (!NODE.test(String(id || ''))) return { ok: false, problem: { status: 422, code: 'machines.invalid', detail: 'that is not one of your machines' } };
+ let out;
+ try { out = await call(token, 'POST', `/api/v1/me/nodes/${encodeURIComponent(id)}/revoke`, {}); } catch (e) {
+ return { ok: false, problem: { status: 503, code: 'machines.unavailable', detail: `OpenVibe.Network did not answer: ${String(e && e.message).slice(0, 120)}` } };
+ }
+ if (!out.ok) {
+ const doc = out.doc || {};
+ if (out.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'machines.expired' });
+ return { ok: false, problem: { status: out.status, code: doc.code || 'machines.unavailable', detail: doc.detail || doc.error || `Network answered ${out.status}` } };
+ }
+ return { ok: true, machine: present(out.doc) };
+ }
+
+ return { list, revoke, present, STATE_WORD };
+}
+
+module.exports = { createMachines, present, STATE_WORD, ONLINE_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * What a person's tasks used, day by day (Codes C4b).
+ *
+ * Codes knows its own work: a task (run_tasks) and every attempt on it (codes_attempts), with the minutes between an
+ * attempt's start and its end and the tokens its session reported. This module adds those up per calendar day.
+ *
+ * It knows no money. Prices are OpenVibe.Billing's: Billing's /api/v1/usage read is its ledger admin's
+ * (billing.ledger.admin), and Codes holds no such grant, so the Usage screen shows runs, minutes and tokens and says
+ * out loud that it shows no prices — it does not invent a ledger from its own cost fields.
+ *
+ * createUsage({ store })
+ * daily(subject, { days }) → { days: [{ day, runs, attempts, minutes, tokens }], totals, window }
+ */
+
+const MAX_DAYS = 90;
+const DEFAULT_DAYS = 14;
+
+const num = (v) => (v == null || !Number.isFinite(Number(v)) ? 0 : Number(v));
+
+function createUsage({ store }) {
+ const db = store.db;
+
+ /**
+ * The person's days, newest first. A day with no attempt is left out: a table of empty rows says nothing.
+ * `minutes` is the time an attempt was running, to the minute; `tokens` is what its session reported.
+ */
+ async function daily(subject, { days = DEFAULT_DAYS } = {}) {
+ const window = Math.min(MAX_DAYS, Math.max(1, Number(days) || DEFAULT_DAYS));
+ if (!subject) return { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window };
+ const rows = await db.many(
+ `SELECT to_char(date_trunc('day', COALESCE(a.started_at, a.created_at)), 'YYYY-MM-DD') AS day,
+ count(DISTINCT a.task_id)::int AS runs,
+ count(a.id)::int AS attempts,
+ COALESCE(sum(GREATEST(0, EXTRACT(EPOCH FROM (a.finished_at - a.started_at)) / 60)), 0) AS minutes,
+ COALESCE(sum(a.tokens), 0) AS tokens
+ FROM codes_attempts a JOIN run_tasks t ON t.id = a.task_id
+ WHERE t.subject = $1 AND COALESCE(a.started_at, a.created_at) > now() - ($2::int * interval '1 day')
+ GROUP BY 1 ORDER BY 1 DESC`,
+ [subject, window],
+ );
+ const out = rows.map((r) => ({
+ day: String(r.day),
+ runs: num(r.runs),
+ attempts: num(r.attempts),
+ minutes: Math.round(num(r.minutes)),
+ tokens: num(r.tokens),
+ }));
+ const totals = out.reduce((a, d) => ({
+ runs: a.runs + d.runs, attempts: a.attempts + d.attempts, minutes: a.minutes + d.minutes, tokens: a.tokens + d.tokens,
+ }), { runs: 0, attempts: 0, minutes: 0, tokens: 0 });
+ return { days: out, totals, window };
+ }
+
+ return { daily, MAX_DAYS, DEFAULT_DAYS };
+}
+
+module.exports = { createUsage, MAX_DAYS, DEFAULT_DAYS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+
+/**
+ * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications and the safety
+ * level. One row per person in codes_user_settings (migration 0013); a person without a row reads the defaults, so the
+ * Settings screen is complete before anything is saved.
+ *
+ * createUserSettings({ store })
+ * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety }
+ * save(subject, patch) → the saved row, in the same shape
+ *
+ * Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
+ */
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SAFETY = ['ask', 'project', 'full'];
+const AGENT = /^[a-z0-9-]{1,40}$/;
+const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const DEFAULTS = Object.freeze({ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask' });
+
+function present(row) {
+ if (!row) return { ...DEFAULTS };
+ return {
+ default_agent: row.default_agent || null,
+ default_machine: row.default_machine || null,
+ notify_done: row.notify_done !== false,
+ notify_needs: row.notify_needs !== false,
+ safety: SAFETY.includes(row.safety) ? row.safety : 'ask',
+ };
+}
+
+function createUserSettings({ store }) {
+ const db = store.db;
+
+ async function get(subject) {
+ if (!USER.test(String(subject || ''))) return { ...DEFAULTS };
+ return present(await db.maybe('SELECT * FROM codes_user_settings WHERE subject = $1', [subject]));
+ }
+
+ /**
+ * Save the choices a form sent. A value that is not one Codes knows is refused with a 422 rather than stored, and a
+ * field the form did not send keeps what it had: a partly filled form never wipes the rest.
+ */
+ async function save(subject, patch = {}) {
+ if (!USER.test(String(subject || ''))) {
+ throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });
+ }
+ const now = present(await db.maybe('SELECT * FROM codes_user_settings WHERE subject = $1', [subject]));
+ const next = { ...now };
+ if (patch.default_agent !== undefined) {
+ const v = patch.default_agent === '' || patch.default_agent == null ? null : String(patch.default_agent);
+ if (v !== null && !AGENT.test(v)) throw Object.assign(new Error('the default agent is a harness id, or empty for Auto'), { status: 422, code: 'settings.agent_unknown' });
+ next.default_agent = v;
+ }
+ if (patch.default_machine !== undefined) {
+ const v = patch.default_machine === '' || patch.default_machine == null ? null : String(patch.default_machine);
+ if (v !== null && !MACHINE.test(v)) throw Object.assign(new Error('the default machine is one of your machines, or empty for Auto'), { status: 422, code: 'settings.machine_unknown' });
+ next.default_machine = v;
+ }
+ if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';
+ if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';
+ if (patch.safety !== undefined) {
+ const v = String(patch.safety);
+ if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });
+ next.safety = v;
+ }
+ await db.exec(
+ `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, now())
+ ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
+ notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety, updated_at = now()`,
+ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety],
+ );
+ return next;
+ }
+
+ return { get, save, DEFAULTS, SAFETY };
+}
+
+module.exports = { createUserSettings, present, DEFAULTS, SAFETY };
@@ -35,6 +35,9 @@ const BUDGETS = {
@@ -35,6 +35,9 @@ const BUDGETS = {
'codes.harness.route': { minute: 30, hour: 600 },
'codes.harness.route': { minute: 30, hour: 600 },
// A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).
// A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).
'codes.task.start': { minute: 6, hour: 60 },
'codes.task.start': { minute: 6, hour: 60 },
+ // The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's
+ // own repositories come first and cost nothing; this bounds what they may spend on the rest.
+ 'codes.repo.search': { minute: 6, hour: 60 },
};
};
/**
/**
@@ -1,6 +1,19 @@
@@ -1,6 +1,19 @@
'use strict';
'use strict';
-// Phone-first Codes workspace. Pages stay useful without JavaScript and link to existing task actions.
+/**
+ * Phone-first Codes workspace (C4, finished in C4b). Pages stay useful without JavaScript: every panel is
+ * server-rendered, and the script only adds the repository search and the fold-out proofs' details.
+ *
+ * / the composer with the person's saved repositories and machines (last used first), running work
+ * /tasks, /tasks/:id the person's tasks; a task's Live, Changes, Proof and Pull request panels are Codes' own
+ * /agents the person's connections: their provider keys (they live in OpenVibe.AI), added, tested, removed
+ * /machines their paired Node machines, their state, and Node's pairing flow with a one-time code
+ * /usage what Codes knows: runs, minutes and tokens per day (no prices: Billing is Billing's)
+ * /settings their choices, remembered per person (codes_user_settings)
+ *
+ * Every write is a form post from this site (Origin checked), every read is the signed-in person's own.
+ */
+const express = require('express');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { raw, esc } = require('../render/html');
const { raw, esc } = require('../render/html');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -37,44 +50,225 @@ const repoDetails = (repo) => {
@@ -37,44 +50,225 @@ const repoDetails = (repo) => {
} catch { return null; }
} catch { return null; }
};
};
const icon = (path) => `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
const icon = (path) => `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
+const asObject = (v) => {
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
+ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
+ return null;
+};
+const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'DeepSeek', minimax: 'MiniMax', openrouter: 'OpenRouter', custom: 'Custom (OpenAI-compatible)' };
function createC4Routes(ctx) {
function createC4Routes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
- const { config, store, harnesses, tasks } = ctx;
+ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage } = ctx;
const db = store.db;
const db = store.db;
- const page = (req, res, title, body, extra = {}) => send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: /^\/tasks\/tsk_/.test(req.path) ? ['js/c4.js'] : [], body: raw(body), ...extra });
+ const form = express.urlencoded({ extended: false, limit: '16kb' });
+ const page = (req, res, title, body, extra = {}) => {
+ const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines)/.test(req.path);
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
+ };
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
- const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(LINKS.find((x) => x[0] === label)[2])}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0,5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
+ const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0, 5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+ const flash = (req) => {
+ const ok = String(req.query.ok || '').slice(0, 120);
+ const error = String(req.query.error || '').slice(0, 200);
+ return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;
+ };
+ const note = (text, kind = '') => `<div class="notice c4-notice${kind ? ` c4-notice-${kind}` : ''}" role="status">${text}</div>`;
+ const pill = (text, kind = '') => `<span class="c4-pill${kind ? ` c4-pill-${kind}` : ''}">${esc(text)}</span>`;
+
async function ownTasks(req, limit = 100) {
async function ownTasks(req, limit = 100) {
if (!viewer(req)) return [];
if (!viewer(req)) return [];
return db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT $2', [req.viewer.subject, limit]);
return db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT $2', [req.viewer.subject, limit]);
}
}
+ /** The person's machines, read with their own token; a Network that does not answer is said, not hidden. */
+ async function machinesOf(req, res = null) {
+ if (!viewer(req)) return { ok: false, machines: [] };
+ try {
+ return await sso.asViewer(req, res, (token) => machines.list(token));
+ } catch (e) {
+ return { ok: false, machines: [], problem: { status: e && e.status, detail: 'your machines could not be read: sign in again' } };
+ }
+ }
+ /** The person's own recent repositories (no GitHub call): what the pickers offer first. */
+ async function recentRepos(req, limit = 8) {
+ if (!viewer(req)) return [];
+ try { return await github.recent(req.viewer.subject, limit); } catch { return []; }
+ }
+ /** The machines the person's tasks ran on, most recently first: the picker's order. */
+ async function lastUsedMachines(req, limit = 20) {
+ if (!viewer(req)) return [];
+ try {
+ const rows = await db.many(
+ `SELECT a.machine, max(COALESCE(a.finished_at, a.started_at, a.created_at)) AS at
+ FROM codes_attempts a JOIN run_tasks t ON t.id = a.task_id
+ WHERE t.subject = $1 AND a.machine IS NOT NULL GROUP BY a.machine ORDER BY at DESC LIMIT $2`,
+ [req.viewer.subject, limit],
+ );
+ return rows.map((r) => String(r.machine));
+ } catch { return []; }
+ }
+ /**
+ * The person's machines in the order they read: the ones their work ran on last come first, the ones never used
+ * come after them. Their saved default is marked, not moved: the order the brief asks for is the order of use.
+ */
+ const orderMachines = (list, used) => {
+ const rank = new Map(used.map((m, i) => [m, i]));
+ const rankOf = (m) => (rank.has(m.id) ? rank.get(m.id) : rank.has(m.node_id) ? rank.get(m.node_id) : 1e6);
+ return [...(list || [])].sort((a, b) => rankOf(a) - rankOf(b));
+ };
+ /** One machine as a picker option: its name, its state, and whether it is the person's saved default. */
+ const machineOption = (m, settings) => {
+ const saved = settings && settings.default_machine === m.id;
+ const state = m.state === 'online' ? '' : m.state === 'revoked' ? ' (revoked)' : ' (seen before)';
+ return `<option value="${esc(m.id)}"${saved ? ' selected' : ''}>${esc(m.name || m.node_id || m.id)}${state}${saved ? ' · your default' : ''}</option>`;
+ };
+
const taskHref = (row) => `/tasks/${encodeURIComponent(row.id)}`;
const taskHref = (row) => `/tasks/${encodeURIComponent(row.id)}`;
const rowHtml = (row) => {
const rowHtml = (row) => {
const repo = repoDetails(row.repo);
const repo = repoDetails(row.repo);
- const attempts = Number(row.attempt_count || row.result?.attempts?.length || row.result?.attempts || 0);
- const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), money(row.result && row.result.cost), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');
+ const attempts = Number(row.attempts_count || row.result?.attempts?.length || row.result?.attempts || 0);
+ const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');
return `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(stateLabel(row.state))}"></span><span class="c4-task-title">${esc(row.prompt || repo?.label || 'Coding task')}</span><span class="c4-task-meta">${meta}</span></a>`;
return `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(stateLabel(row.state))}"></span><span class="c4-task-title">${esc(row.prompt || repo?.label || 'Coding task')}</span><span class="c4-task-meta">${meta}</span></a>`;
};
};
const header = (title, desc = '') => `<div class="c4-title"><div><h1>${esc(title)}</h1>${desc ? `<p>${esc(desc)}</p>` : ''}</div></div>`;
const header = (title, desc = '') => `<div class="c4-title"><div><h1>${esc(title)}</h1>${desc ? `<p>${esc(desc)}</p>` : ''}</div></div>`;
const stateChip = (state) => `<span class="c4-status c4-${esc(state)}">${esc(stateLabel(state))}</span>`;
const stateChip = (state) => `<span class="c4-status c4-${esc(state)}">${esc(stateLabel(state))}</span>`;
- const taskChips = (row) => {
+ const taskChips = (row, machineName = null) => {
const repo = repoDetails(row.repo);
const repo = repoDetails(row.repo);
- const chips = [repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : `<span class="c4-chip">Project not set</span>`, `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`, `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`, '<span class="c4-chip">Your computer</span>', `<span class="c4-chip">${esc(money(row.result && row.result.cost))}</span>`];
+ const chips = [
+ repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : '<span class="c4-chip">Project not set</span>',
+ `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`,
+ `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`,
+ `<span class="c4-chip">${esc(machineName || 'Your computer')}</span>`,
+ `<span class="c4-chip">${esc(money(row.result && row.result.cost_usd))}</span>`,
+ ];
return `<div class="c4-chips" aria-label="Task details">${chips.join('')}</div>`;
return `<div class="c4-chips" aria-label="Task details">${chips.join('')}</div>`;
};
};
+ // ── The outcome row: one calm row, with pill actions ────────────────────────────────────────────────────────
+ const outcomeRow = (row) => {
+ const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);
+ if (!hasOutcome) return '';
+ const prompt = encodeURIComponent(row.prompt || '');
+ const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;
+ const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;
+ return `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>`;
+ };
+
+ /** One proof line: a calm row that folds out to its detail. ok / skipped / failed is on the row itself. */
+ const proofRow = (item) => {
+ const state = item.skipped ? 'skip' : (item.ok ? 'ok' : 'bad');
+ const word = item.skipped ? 'Skipped' : (item.ok ? 'Passed' : 'Failed');
+ const detail = asObject(item.detail) || {};
+ let body = '';
+ if (item.kind === 'tests') {
+ body = `${detail.command ? `<p class="c4-kv"><span>Command</span><code>${esc(detail.command)}</code></p>` : ''}${item.summary ? `<pre tabindex="0" class="c4-code c4-code-small">${esc(item.summary)}</pre>` : '<p class="c4-muted">No output was kept.</p>'}`;
+ } else if (item.kind === 'screenshots') {
+ const shots = Array.isArray(detail.shots) ? detail.shots : [];
+ body = shots.length
+ ? `<ul class="c4-shots">${shots.map((s) => {
+ const image = typeof s.data === 'string' && /^data:image\//.test(s.data) ? s.data : (typeof s.thumb === 'string' && /^data:image\//.test(s.thumb) ? s.thumb : null);
+ const label = `${s.route || '/'} at ${Number(s.width) || 0} px`;
+ const state = s.skipped ? 'Skipped' : (s.ok === false ? 'Failed' : 'Passed');
+ const tile = image
+ ? `<a class="c4-shot" href="${esc(image)}" target="_blank" rel="noopener"><img src="${esc(image)}" alt="${esc(label)}" loading="lazy" decoding="async"><span>${esc(label)}</span>${pill(state, s.ok === false ? 'bad' : 'ok')}</a>`
+ : `<span class="c4-shot c4-shot-ghost">${icon('<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>')}<span>${esc(label)}</span>${pill(state, s.ok === false ? 'bad' : 'ok')}</span>`;
+ const why = [s.summary, s.overflow ? 'the page scrolls sideways at this width' : null, Array.isArray(s.errors) && s.errors.length ? `the page threw: ${s.errors[0]}` : null].filter(Boolean)[0];
+ return `<li>${tile}${why ? `<p class="c4-shot-why">${esc(why)}</p>` : ''}</li>`;
+ }).join('')}</ul><p class="c4-hint">The pictures stay on the machine that took them${shots.some((s) => s && s.path) ? ' (kept there as PNG files)' : ''}; the row carries what each one found.</p>`
+ : '<p class="c4-muted">No page was shot for this task.</p>';
+ } else if (item.kind === 'diff') {
+ const files = Array.isArray(detail.files) ? detail.files : [];
+ body = `${detail.stats ? `<pre tabindex="0" class="c4-code c4-code-small">${esc(detail.stats)}</pre>` : ''}${files.length ? `<ul class="c4-filelist">${files.map((f) => `<li><code>${esc(f)}</code></li>`).join('')}</ul>` : ''}${!detail.stats && !files.length ? '<p class="c4-muted">The diff was not kept with the proof.</p>' : ''}`;
+ } else if (item.kind === 'review') {
+ body = `<p class="c4-kv"><span>Reviewer</span><b>${esc(item.family || 'another model')}</b></p>${item.summary ? `<p class="c4-review">${esc(item.summary)}</p>` : ''}`;
+ } else {
+ body = item.summary ? `<p class="c4-review">${esc(item.summary)}</p>` : '<p class="c4-muted">Nothing more was kept.</p>';
+ }
+ return `<li class="c4-fold-row"><details class="c4-fold"><summary><span class="c4-row-dot c4-dot-${state}" aria-hidden="true"></span><span class="c4-row-label">${esc(item.label || item.kind)}</span><span class="c4-row-note">${esc(item.summary || (item.skipped ? 'it could not run here' : ''))}</span>${pill(word, state === 'ok' ? 'ok' : state === 'skip' ? 'skip' : 'bad')}</summary><div class="c4-row-body">${body}</div></details></li>`;
+ };
+
+ /** The Changes panel: the changed files first, then the diff itself, folded out. */
+ const changesPanel = (row) => {
+ const result = asObject(row.result) || {};
+ const files = Array.isArray(result.files) ? result.files.filter((f) => f && f.path) : [];
+ const patch = typeof result.patch === 'string' ? result.patch : '';
+ if (!files.length && !patch) {
+ return `<p class="c4-muted">${['queued', 'placed', 'running'].includes(row.state) ? 'The changes appear here when the task ends.' : 'This task changed no files.'}</p>`;
+ }
+ const added = files.reduce((n, f) => n + (Number(f.added) || 0), 0);
+ const removed = files.reduce((n, f) => n + (Number(f.removed) || 0), 0);
+ return `<p class="c4-panel-lede">${files.length} file${files.length === 1 ? '' : 's'} changed · <span class="c4-add">+${added}</span> <span class="c4-del">−${removed}</span></p>
+${files.length ? `<ul class="c4-filelist c4-filelist-counts">${files.map((f) => `<li><code>${esc(f.path)}</code><span class="c4-add">+${f.added == null ? '·' : esc(f.added)}</span><span class="c4-del">−${f.removed == null ? '·' : esc(f.removed)}</span></li>`).join('')}</ul>` : ''}
+${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-label">The diff</span><span class="c4-row-note">${result.patch_truncated ? 'cut to fit' : `${patch.split('\n').length} lines`}</span>${pill('Show', 'skill')}</summary><div class="c4-row-body"><pre tabindex="0" class="c4-diff">${esc(patch)}</pre></div></details>` : ''}`;
+ };
+
+ /** The Pull request panel: the link, its CI state and its merge state when the task opened one. */
+ const pullRequestPanel = async (row) => {
+ const result = asObject(row.result) || {};
+ const push = asObject(result.push) || {};
+ const href = typeof push.pull_request === 'string' ? push.pull_request : null;
+ if (!href) {
+ const why = push.pull_request_error
+ ? `The pull request was not opened: ${esc(push.pull_request_error)}`
+ : (push.pushed ? 'The branch was pushed, but no pull request was asked for.' : (!row.pull_request ? 'This task did not ask for a pull request.' : 'The task did not reach the push.'));
+ const prompt = encodeURIComponent(row.prompt || '');
+ return `<p class="c4-muted">${why}</p>${push.branch_url ? `<p class="c4-muted">Branch: <a href="${esc(push.branch_url)}" rel="noopener noreferrer">${esc(push.branch || 'the new branch')}</a></p>` : ''}<p><a class="c4-opill" href="/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&push=1&pull_request=1">${icon('<path d="M6 3v12a3 3 0 0 0 3 3h6"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/>')}<span>Start one with a pull request</span></a></p>`;
+ }
+ const pr = await github.pullRequest(href);
+ const stateWord = pr.problem ? 'Not read' : (pr.state === 'merged' ? 'Merged' : pr.state === 'closed' ? 'Closed' : 'Open');
+ const stateKind = pr.problem ? 'skip' : (pr.state === 'merged' ? 'ok' : pr.state === 'closed' ? 'bad' : 'open');
+ return `<div class="c4-rows"><div class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${stateKind}" aria-hidden="true"></span><span class="c4-row-label">Pull request #${esc(pr.number || '')}</span><span class="c4-row-note">${esc(pr.title || `${pr.owner || ''}/${pr.name || ''}`)}</span>${pill(stateWord, stateKind)}</div>
+<div class="c4-pr-actions"><a class="c4-opill c4-opill-primary" href="${esc(href)}" rel="noopener noreferrer">${icon('<path d="M14 3h7v7M21 3l-9 9"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>')}<span>Open on GitHub</span></a>${pr.branch ? `<span class="c4-chip">${esc(pr.branch)} → ${esc(pr.base || 'base')}</span>` : ''}</div>
+${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;
+ };
+
+ // ── Home ────────────────────────────────────────────────────────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const rows = await ownTasks(req, 8);
const rows = await ownTasks(req, 8);
const signedIn = !!viewer(req);
const signedIn = !!viewer(req);
+ const settings = signedIn ? await userSettings.get(req.viewer.subject) : null;
+ const repos = signedIn ? await recentRepos(req) : [];
+ const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };
+ const used = signedIn ? await lastUsedMachines(req) : [];
+ const machineList = orderMachines(mine.machines, used);
+ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
+ const machineOptions = machineList.map((m) => machineOption(m, settings)).join('');
+ const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
-<section class="c4-card c4-composer"><form method="get" action="/run"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label>Project<input type="url" name="repo" placeholder="Paste a repository URL" autocomplete="url"></label><label>Agent <select name="agent"><option value="">Auto</option>${(harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><label>Machine <select name="machine"><option value="">Auto</option></select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div></form></section>
-<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done','succeeded','failed','cancelled'].includes(x.state)).slice(0,4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
-<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0,5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
-${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent','Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'],['Your computer or ours','Pair your computer with one command. A hosted sandbox is coming soon.'],['Proof you can inspect','See the changes, tests, screenshots and review before you approve.'],['A cost you can follow','Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a,b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
+<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
+<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
+<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
+${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
+${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
});
});
+
+ /**
+ * The composer's first step: the pickers are saved for this person, then Run's own form opens with the task
+ * prefilled. Saving is best effort — a picker that cannot be saved must not stop the task.
+ */
+ r.post('/start', form, async (req, res) => {
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ if (viewer(req) && (b.machine || b.agent)) {
+ try {
+ await userSettings.save(req.viewer.subject, {
+ ...(b.machine ? { default_machine: b.machine } : {}),
+ ...(b.agent ? { default_agent: b.agent } : {}),
+ });
+ } catch (e) { /* a picker that cannot be saved does not stop the task */ }
+ }
+ const q = new URLSearchParams();
+ for (const key of ['prompt', 'repo', 'agent']) if (b[key]) q.set(key, String(b[key]).slice(0, key === 'prompt' ? 16000 : 300));
+ return res.redirect(303, `/run${q.toString() ? `?${q}` : ''}`);
+ });
+
r.get('/tasks', async (req, res) => {
r.get('/tasks', async (req, res) => {
const rows = await ownTasks(req);
const rows = await ownTasks(req);
const q = String(req.query.q || '').toLowerCase();
const q = String(req.query.q || '').toLowerCase();
@@ -90,35 +284,209 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -90,35 +284,209 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));
page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));
});
});
+
r.get('/tasks/:id', async (req, res) => {
r.get('/tasks/:id', async (req, res) => {
if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));
if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));
if (!safeId(req.params.id)) return res.status(404).send('Not found');
if (!safeId(req.params.id)) return res.status(404).send('Not found');
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
if (!row) return res.status(404).send('Not found');
- const live = ['queued','placed','running'].includes(row.state);
+ const live = ['queued', 'placed', 'running'].includes(row.state);
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attemptItems = attempts.map((attempt) => {
const attemptItems = attempts.map((attempt) => {
const item = tasks.attempts.present(attempt);
const item = tasks.attempts.present(attempt);
return `<li class="c4-attempt" data-attempt-n="${Number(item.n)}">Attempt ${Number(item.n)} · ${esc(item.agent || 'Auto')} · ${esc(item.end_why || item.state || 'Queued')}</li>`;
return `<li class="c4-attempt" data-attempt-n="${Number(item.n)}">Attempt ${Number(item.n)} · ${esc(item.agent || 'Auto')} · ${esc(item.end_why || item.state || 'Queued')}</li>`;
}).join('');
}).join('');
+ // The machine the task ran on, in the person's own words when their list knows it: never a bare principal id.
+ let machineName = attempts.map((a) => a.machine).filter(Boolean).at(-1) || null;
+ if (machineName) {
+ const known = await machinesOf(req, res);
+ const hit = (known.machines || []).find((m) => m.id === machineName || m.node_id === machineName);
+ machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^nod_/.test(machineName) ? 'Your machine' : machineName);
+ }
const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
const events = `${taskApi}/events`;
const events = `${taskApi}/events`;
const prompt = encodeURIComponent(row.prompt || '');
const prompt = encodeURIComponent(row.prompt || '');
const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;
const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;
const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;
const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;
- const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);
- const outcome = hasOutcome ? `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>` : '';
const progress = live ? `<button class="c4-progress-toggle" type="button" aria-expanded="false" aria-controls="live-content"><span data-progress-title>Working on your task</span><span class="c4-progress-meta"><span data-progress-steps>0 steps</span> · <span data-progress-time>now</span></span><span aria-hidden="true">⌄</span></button>` : '';
const progress = live ? `<button class="c4-progress-toggle" type="button" aria-expanded="false" aria-controls="live-content"><span data-progress-title>Working on your task</span><span class="c4-progress-meta"><span data-progress-steps>0 steps</span> · <span data-progress-time>now</span></span><span aria-hidden="true">⌄</span></button>` : '';
- const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcome}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${hasOutcome ? '' : stateChip(row.state)}</div>${taskChips(row)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live','Live'],['changes','Changes'],['proof','Proof'],['pull-request','Pull request']].map(([id, label], i) => `<a href="#${id}" role="tab" aria-selected="${i === 0}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel"><p>Changes from this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open changes</a></section><section id="proof" class="c4-panel" role="tabpanel"><p>Test output and screenshots for this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task proof</a></section><section id="pull-request" class="c4-panel" role="tabpanel"><p>Pull request details will appear here.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task in Run</a></section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;
+ const proofItems = tasks?.proof ? tasks.proof.present(await tasks.proof.list(row.id)) : [];
+ const resultNow = asObject(row.result) || {};
+ const hasChanges = (Array.isArray(resultNow.files) && resultNow.files.length > 0) || typeof resultNow.patch === 'string';
+ // A finished task opens on what it proved; a running one opens on its live lines.
+ const preferredTab = live ? 'live' : (proofItems.length ? 'proof' : (hasChanges ? 'changes' : 'live'));
+ const proofPanel = proofItems.length
+ ? `<ul class="c4-rows">${proofItems.map(proofRow).join('')}</ul><p class="c4-hint">Proof, not promises: the tests the change reached, the pages it touched, the diff, and a second model's opinion.</p>`
+ : `<p class="c4-muted">${live ? 'The proof is collected when the task ends: its tests, its pages, its diff and a second opinion.' : 'This task has no proof: it ended before proof was collected.'}</p>`;
+ const prPanel = await pullRequestPanel(row);
+ const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcomeRow(row)}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${row.outcome === 'unfinished' || row.error ? '' : stateChip(row.state)}</div>${taskChips(row, machineName)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live', 'Live'], ['changes', 'Changes'], ['proof', 'Proof'], ['pull-request', 'Pull request']].map(([id, label]) => `<a href="#${id}" role="tab" aria-selected="${id === preferredTab}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel">${changesPanel(row)}</section><section id="proof" class="c4-panel" role="tabpanel">${proofPanel}</section><section id="pull-request" class="c4-panel" role="tabpanel">${prPanel}</section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;
page(req, res, 'Task', wrap('/tasks', content, true));
page(req, res, 'Task', wrap('/tasks', content, true));
});
});
- r.get('/agents', (req, res) => page(req, res, 'Agents', wrap('/agents', `${header('Agents')}${viewer(req) ? '<section id="connections" class="c4-card"><h2>Connections</h2><div class="c4-emptyline">No agent connections yet. <a class="c4-button" href="/agents#connections">Connect a key</a></div></section>' : signIn(req)}`, !!viewer(req))));
- r.get('/machines', (req, res) => page(req, res, 'Machines', wrap('/machines', `${header('Machines')}${viewer(req) ? '<section class="c4-card"><h2>Your machines</h2><div class="c4-emptyline">No paired computers yet. <a class="c4-button" href="/machines#pair">Pair this computer</a></div><div id="pair"><pre class="c4-code">curl -fsSL https://openvibe.bot/install.sh | sh -s -- --worker</pre></div></section>' : signIn(req)}`, !!viewer(req))));
+
+ // ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
+ const connectionsCard = async (req) => {
+ let list = { keys: [], plans: [] };
+ let problem = null;
+ try { list = await connections.list(req.viewer.subject); } catch (e) {
+ problem = { status: e && e.status, detail: (e && e.message) || 'the connections could not be read' };
+ }
+ const now = Date.now();
+ const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');
+ const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
+ return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2><span class="c4-muted">Keys live in OpenVibe.AI, never here</span></div>
+${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}
+${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}
+<h3 class="c4-subhead">Add or replace a key</h3>
+<form class="c4-form" method="post" action="/agents/connections">
+ <label>Provider <select name="provider">${connections.PROVIDERS.map((p) => `<option value="${esc(p)}">${esc(PROVIDER_NAMES[p] || p)}</option>`).join('')}</select></label>
+ <label>Key <input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="sk-…"></label>
+ <label>Base URL <input type="url" name="base_url" placeholder="Only for a custom provider" autocomplete="off"></label>
+ <label>Daily cap <input type="number" name="budget_usd_per_day" min="0" step="0.01" placeholder="Optional, in dollars"></label>
+ <button class="c4-button" type="submit">Save the key</button>
+</form>
+<p class="c4-hint">The key is forwarded to OpenVibe.AI and sealed there; Codes keeps only the four-character hint. Test sends one tiny request with your key.</p>
+</section>`;
+ };
+
+ r.get('/agents', async (req, res) => {
+ const content = `${header('Agents', 'The agents your tasks can run, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await connectionsCard(req)}` : signIn(req)}`;
+ page(req, res, 'Agents', wrap('/agents', content, !!viewer(req)));
+ });
+ r.post('/agents/connections', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ try {
+ const out = await connections.put(req.viewer.subject, b.provider, {
+ key: b.key, ...(b.base_url ? { base_url: b.base_url } : {}),
+ ...(b.budget_usd_per_day ? { budget_usd_per_day: b.budget_usd_per_day } : {}),
+ });
+ return res.redirect(303, `/agents?ok=${encodeURIComponent(`Saved the ${PROVIDER_NAMES[out.provider] || out.provider} key`)}`);
+ } catch (e) {
+ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key was refused').slice(0, 180))}`);
+ }
+ });
+ r.post('/agents/connections/:provider/test', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ try {
+ const out = await connections.test(req.viewer.subject, req.params.provider);
+ const word = out.ok ? `The ${PROVIDER_NAMES[req.params.provider] || req.params.provider} key works` : `The key did not work: ${out.detail}`;
+ return res.redirect(303, `/agents?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(String(word).slice(0, 180))}`);
+ } catch (e) {
+ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key could not be tested').slice(0, 180))}`);
+ }
+ });
+ r.post('/agents/connections/:provider/remove', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ try {
+ const gone = await connections.remove(req.viewer.subject, req.params.provider);
+ return res.redirect(303, `/agents?${gone ? 'ok' : 'error'}=${encodeURIComponent(gone ? `Forgot the ${PROVIDER_NAMES[req.params.provider] || req.params.provider} key` : 'There was no key for that provider')}`);
+ } catch (e) {
+ return res.redirect(303, `/agents?error=${encodeURIComponent(String((e && e.message) || 'the key could not be forgotten').slice(0, 180))}`);
+ }
+ });
+
+ // ── Machines: Node's pairing flow with a one-time code, and each machine's state ───────────────────────────
+ const CODE_SHAPE = /^[0-9A-HJKMNP-TV-Z]{8}$/;
+ const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
+ r.get('/machines', async (req, res) => {
+ const mine = viewer(req) ? await machinesOf(req, res) : { ok: false, machines: [] };
+ const raw = normaliseCode(req.query.code);
+ const badCode = raw.length > 0 && !CODE_SHAPE.test(raw);
+ const code = CODE_SHAPE.test(raw) ? `${raw.slice(0, 4)}-${raw.slice(4)}` : '';
+ const list = (mine.machines || []).map((m) => {
+ const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';
+ return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;
+ }).join('');
+ const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2><span class="c4-muted">One command, one code</span></div>
+<ol class="c4-steps">
+<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>
+<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --worker</code></li>
+<li><b>It pairs itself</b>, then appears here as Online. A machine runs your jobs and nobody else's.</li>
+</ol>
+<form class="c4-form c4-form-inline" method="get" action="/machines">
+ <label>Have a code? <input name="code" value="${esc(String(req.query.code || ''))}" placeholder="ABCD-1234" inputmode="text" autocomplete="off" spellcheck="false" maxlength="12"></label>
+ <button class="c4-button" type="submit">Show the command</button>
+</form>
+${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}
+${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}
+</section>`;
+ const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2><span class="c4-muted">${(mine.machines || []).length} paired</span></div>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;
+ page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));
+ });
+ r.post('/machines/:id/revoke', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/machines')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ try {
+ const out = await sso.asViewer(req, res, (token) => machines.revoke(token, req.params.id));
+ return res.redirect(303, `/machines?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(out.ok ? 'The machine is revoked: it can no longer run your work' : (out.problem && out.problem.detail) || 'the machine could not be revoked')}`);
+ } catch (e) {
+ return res.redirect(303, `/machines?error=${encodeURIComponent(String((e && e.message) || 'the machine could not be revoked').slice(0, 180))}`);
+ }
+ });
+
+ // ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────
r.get('/usage', async (req, res) => {
r.get('/usage', async (req, res) => {
- const rows = await ownTasks(req);
- page(req, res, 'Usage', wrap('/usage', `${header('Usage')}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Recent task costs</h2><span>Last ${rows.length} tasks</span></div>${rows.map((x) => `<a class="c4-task c4-usage-row" href="${taskHref(x)}"><span class="c4-task-title">${esc(x.prompt || repoDetails(x.repo)?.label || 'Task')}</span><span class="c4-task-meta">${esc(x.agent || 'Auto')} · ${esc(stateLabel(x.state))}</span><b>${esc(money(x.result && x.result.cost))}</b></a>`).join('') || '<div class="c4-emptyline">No task usage yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>` : signIn(req)}`, !!viewer(req)));
+ let report = { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window: 14 };
+ if (viewer(req)) report = await usage.daily(req.viewer.subject, { days: 14 });
+ const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');
+ const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2><span class="c4-muted">Totals below</span></div>
+${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>
+<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}
+<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
+</section>
+<section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;
+ page(req, res, 'Usage', wrap('/usage', content, !!viewer(req)));
+ });
+
+ // ── Settings: remembered per person ────────────────────────────────────────────────────────────────────────
+ const SAFETY_OPTIONS = [
+ ['ask', 'Ask me', 'Approve commands as they come up.'],
+ ['project', 'Auto in the project', 'Run commands within the project automatically.'],
+ ['full', 'Full access', 'Let the agent use the whole computer.'],
+ ];
+ r.get('/settings', async (req, res) => {
+ const settings = viewer(req) ? await userSettings.get(req.viewer.subject) : null;
+ const mine = viewer(req) ? await machinesOf(req, res) : { machines: [] };
+ const used = viewer(req) ? await lastUsedMachines(req) : [];
+ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
+ // The same order the Home picker shows: the machines their work ran on last come first.
+ const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');
+ const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
+<div class="c4-form-grid">
+<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
+<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
+</div>
+<h3 class="c4-subhead">Notifications</h3>
+<div class="c4-checks">
+ <label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>
+ <label class="c4-check"><input type="checkbox" name="notify_needs" value="1"${settings.notify_needs ? ' checked' : ''}><span><b>A task needs me</b><small>Approvals, questions and a task that stopped.</small></span></label>
+</div>
+<h3 class="c4-subhead">Safety</h3>
+<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>
+<button class="c4-button" type="submit">Save settings</button>
+</form></section>` : signIn(req)}`;
+ page(req, res, 'Settings', wrap('/settings', content, !!viewer(req)));
+ });
+ r.post('/settings', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/settings')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ try {
+ await userSettings.save(req.viewer.subject, {
+ default_agent: b.default_agent === undefined ? undefined : b.default_agent,
+ default_machine: b.default_machine === undefined ? undefined : b.default_machine,
+ notify_done: b.notify_done === '1',
+ notify_needs: b.notify_needs === '1',
+ ...(b.safety ? { safety: b.safety } : {}),
+ });
+ return res.redirect(303, '/settings?ok=Settings%20saved');
+ } catch (e) {
+ return res.redirect(303, `/settings?error=${encodeURIComponent(String((e && e.message) || 'the settings could not be saved').slice(0, 180))}`);
+ }
});
});
- r.get('/settings', (req, res) => page(req, res, 'Settings', wrap('/settings', `${header('Settings')}${viewer(req) ? '<section class="c4-card"><h2>Safety</h2><div class="c4-safety" role="group" aria-label="Safety level"><label class="c4-safety-card"><input type="radio" name="safety" checked><span><b>Ask me</b><small>Approve commands as they come up.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Auto in the project</b><small>Run commands within the project automatically.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Full access</b><small>Let the agent use the whole computer.</small></span></label></div><p class="c4-muted">Arrives with connections.</p></section>' : signIn(req)}`, !!viewer(req))));
+
return r;
return r;
}
}
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* POST /me/tasks → { task: codes.task@1 } (201)
* POST /me/tasks → { task: codes.task@1 } (201)
* GET /me/tasks/:id → codes.task@1
* GET /me/tasks/:id → codes.task@1
* POST /me/tasks/:id/cancel → codes.task@1
* POST /me/tasks/:id/cancel → codes.task@1
+ * GET /me/repos?q=… → { repos: [{ full_name, url, … }] }: the Home picker's search (Codes C4b)
*/
*/
const express = require('express');
const express = require('express');
const { http, validate } = require('openvibe-contracts');
const { http, validate } = require('openvibe-contracts');
@@ -20,7 +21,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -20,7 +21,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
-function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, log = console }) {
+function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, github = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@ -101,6 +102,18 @@ function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks =
@@ -101,6 +102,18 @@ function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks =
return res.status(201).json({ task: taskStore.present(row), created: true });
return res.status(201).json({ task: taskStore.present(row), created: true });
});
});
+ /**
+ * The Home composer's repository search (Codes C4b): the person's own repositories used here, first, then GitHub's
+ * public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that
+ * does not answer is a `problem` beside the repositories Codes already knows, never an empty list pretending so.
+ */
+ r.get('/me/repos', actorLimits.budget('codes.repo.search'), async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to search your repositories');
+ if (!github) return res.json({ q: String(req.query.q || ''), repos: [], problem: { status: 503, detail: 'the repository search is not available here' } });
+ const out = await github.search(req.viewer.subject, req.query.q, { limit: 8 });
+ return res.json({ q: out.q, repos: out.found, problem: out.problem });
+ });
+
r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {
r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
const row = await own(req, req.params.id);
const row = await own(req, req.params.id);
@@ -55,7 +55,9 @@ function renderPage(o) {
@@ -55,7 +55,9 @@ function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
- const c4Workspace = pathNow === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathNow);
+ // The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).
+ const pathOnly = pathNow.split('?')[0];
+ const c4Workspace = pathOnly === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathOnly);
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const nav = {
const nav = {
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');
const settings = await t.get('/settings', { as: ada });
const settings = await t.get('/settings', { as: ada });
assert.strictEqual(settings.status, 200);
assert.strictEqual(settings.status, 200);
assert.ok(settings.text.includes('Safety'));
assert.ok(settings.text.includes('Safety'));
- assert.strictEqual((settings.text.match(/Arrives with connections\./g) || []).length, 1);
+ assert.ok(settings.text.includes('Save settings'));
assert.ok(!settings.text.includes('Sign in to see your work'));
assert.ok(!settings.text.includes('Sign in to see your work'));
const hidden = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV', { as: ada });
const hidden = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV', { as: ada });
assert.strictEqual(hidden.status, 404);
assert.strictEqual(hidden.status, 404);
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+'use strict';
+/**
+ * Codes C4b: the screens C4 left as links.
+ *
+ * - Home's saved pickers: the repositories a person used here, last used first, and their paired machines
+ * - a task's Changes, Proof and Pull request panels are Codes' own, from run_tasks and codes_task_proof
+ * - Agents: the person's keys, added, tested and removed from the page (the key itself never comes back)
+ * - Machines: the person's paired Node machines, their state, and Node's pairing flow with a one-time code
+ * - Settings: remembered per person
+ * - Usage: runs, minutes and tokens per day, and no prices Codes does not know
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const NODE = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+const OTHER = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAC';
+const TASK = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+const TASK2 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAW';
+const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
+
+(async () => {
+ const t = await boot({ env: (network) => ({ CODES_AI_URL: 'https://ai.openvibe.services.invalid', CODES_AI_INTERNAL_URL: network.url }) });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('c4b-ada');
+ const bo = t.network.addUser('c4b-bo');
+ const now = Date.now();
+
+ try {
+ await check('Home offers the repositories a person used, last used first, and their paired machines', async () => {
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at)
+ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','An older task','codex','succeeded','{}'::jsonb,$3,$3),
+ ($4,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Shared','main','A newer task','codex','succeeded','{}'::jsonb,$5,$5)`,
+ [TASK3, ada.subject, now - 90 * 60_000, TASK2, now - 60_000]);
+ t.network.addNode(ada.subject, { principal: OTHER, node_id: 'node-laptop', name: 'Laptop', status: 'active', last_seen_at: new Date().toISOString(), created_at: new Date().toISOString() });
+ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node-desk', name: 'Desk', status: 'active', last_seen_at: new Date().toISOString(), created_at: new Date().toISOString(), paired_for: { service: 'bot', ref: 'rob_1' } });
+ const home = await t.get('/', { as: ada });
+ assert.strictEqual(home.status, 200);
+ const shared = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Shared');
+ const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');
+ assert.ok(shared > -1 && codes > -1, 'both repositories are offered');
+ assert.ok(shared < codes, 'the last one used comes first');
+ assert.ok(home.text.includes('<option value="' + NODE + '">Desk'), 'the machine picker offers the paired machine');
+ assert.ok(home.text.includes('data-c4-repo-search'), 'the repository field searches');
+ assert.ok(home.text.includes('data-c4-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the saved ones are a pill away');
+ });
+
+ await check('the machine picker is in the order of use, on Home and on Settings', async () => {
+ // The Desk ran the work; the Laptop never did, and Network lists the Laptop first.
+ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)
+ VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);
+ await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });
+ for (const page of ['/', '/settings']) {
+ const shown = await t.get(page, { as: ada });
+ assert.strictEqual(shown.status, 200);
+ const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);
+ const laptop = shown.text.indexOf(`<option value="${OTHER}"`);
+ assert.ok(desk > -1 && laptop > -1, `${page} offers both machines`);
+ assert.ok(desk < laptop, `${page} shows the machine used last first`);
+ assert.ok(shown.text.includes('Laptop · your default'), `${page} marks the saved default`);
+ }
+ });
+
+ await check('the composer remembers the machine and agent a person picks, then opens Run’s form', async () => {
+ const started = await t.get('/start', { as: ada, method: 'POST', form: { prompt: 'Fix the header', repo: 'https://github.com/OpenVibers/OpenVibe.Codes', agent: 'codex', machine: NODE } });
+ assert.strictEqual(started.status, 303);
+ const to = String(started.headers.get('location'));
+ assert.ok(to.startsWith('/run?') && to.includes('prompt=Fix+the+header') && to.includes('agent=codex'), 'the task is carried to Run');
+ const saved = await t.ctx.userSettings.get(ada.subject);
+ assert.strictEqual(saved.default_machine, NODE, 'the machine is the default now');
+ assert.strictEqual(saved.default_agent, 'codex', 'and so is the agent');
+ });
+
+ await check('a task’s Changes, Proof and Pull request panels are Codes’ own', async () => {
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, outcome, outcome_note, created_at, updated_at)
+ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Make the task page read well','codex','succeeded',$3::jsonb,'done','Done with proof',$4,$4)`,
+ [TASK, ada.subject, JSON.stringify({ files: [{ path: 'server/http/c4.js', added: 42, removed: 7 }, { path: 'public/css/c4.css', added: 9, removed: 0 }], patch: 'diff --git a/server/http/c4.js b/server/http/c4.js\n+the change', cost_usd: 0.31, push: { pushed: true, branch: 'openvibe-codes/task', pull_request_error: 'GitHub answered 403' } }), now]);
+ await t.ctx.tasks.proof.record(TASK, 1, [
+ { kind: 'tests', label: 'tests', ok: true, summary: 'node test/c4.test.js passed', command: 'node test/c4.test.js' },
+ { kind: 'screenshots', label: 'screenshots', ok: false, summary: '1440px: the page is 1500px wide', shots: [{ ok: true, width: 390, route: '/tasks' }, { ok: false, width: 1440, route: '/tasks', summary: 'the page is 1500px wide', path: '/tmp/proof-tasks-1440.png' }] },
+ { kind: 'diff', label: 'diff', ok: true, summary: '2 files changed', files: ['server/http/c4.js'], stats: ' 2 files changed, 51 insertions(+), 7 deletions(-)' },
+ { kind: 'review', label: 'review', ok: true, skipped: true, summary: 'no model of another family is connected' },
+ ]);
+ const page = await t.get(`/tasks/${TASK}`, { as: ada });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ // Changes: the files first, then the diff folded out.
+ assert.ok(page.text.includes('server/http/c4.js') && page.text.includes('public/css/c4.css'), 'the changed files are listed');
+ assert.ok(page.text.includes('<span class="c4-add">+42</span>') || page.text.includes('c4-add'), 'with their counts');
+ assert.ok(page.text.includes('The diff') && page.text.includes('+the change'), 'and the diff itself');
+ // Proof: one calm row per item, each with its own state.
+ assert.ok(page.text.includes('data-proof-kind') || page.text.includes('c4-row-label">tests'), 'a proof row per item');
+ assert.ok(page.text.includes('Passed') && page.text.includes('Skipped') && page.text.includes('Failed'), 'each row carries its state');
+ assert.ok(page.text.includes('390 px') && page.text.includes('1440 px'), 'the shots are listed by width and route');
+ assert.ok(page.text.includes('the page is 1500px wide'), 'and what the shot found');
+ assert.ok(page.text.includes('no model of another family is connected'), 'the second opinion says why there is none');
+ // Pull request: the link or why there is none, never an invented one.
+ assert.ok(page.text.includes('The pull request was not opened: GitHub answered 403'), 'the push failure is said in its own words');
+ assert.ok(!page.text.includes('/run/tasks/' + TASK), 'no panel links back to Run');
+ });
+
+ await check('a task without proof says so instead of pretending', async () => {
+ const page = await t.get(`/tasks/${TASK2}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('This task has no proof'), 'the empty proof is said');
+ assert.ok(page.text.includes('This task did not ask for a pull request'), 'and the empty pull request too');
+ });
+
+ await check('Agents: a key is added, tested and removed from the page, and never comes back', async () => {
+ // The key is built at run time: a scanner reads a file, and a file that spells out a key is a file with a
+ // key in it.
+ const KEY = ['sk', 'deepseek', '0123456789abcdef'].join('-');
+ const add = await t.get('/agents/connections', { as: ada, form: { provider: 'deepseek', key: KEY, budget_usd_per_day: '3' } });
+ assert.strictEqual(add.status, 303, add.text.slice(0, 200));
+ assert.ok(String(add.headers.get('location')).startsWith('/agents?ok='), 'back to the list with a word');
+ const page = await t.get('/agents', { as: ada });
+ assert.ok(page.text.includes('DeepSeek'), 'the provider is listed');
+ assert.ok(page.text.includes('\u2026cdef'), 'with its hint');
+ assert.ok(!page.text.includes(KEY), 'and never the key');
+ assert.ok(!(await t.dbDump()).includes(KEY), 'nor is it in Codes\' database');
+ const test = await t.get('/agents/connections/deepseek/test', { as: ada, method: 'POST', form: { x: '1' } });
+ assert.strictEqual(test.status, 303);
+ assert.ok(String(test.headers.get('location')).includes('The%20DeepSeek%20key%20works'), 'the test says the key works');
+ const remove = await t.get('/agents/connections/deepseek/remove', { as: ada, method: 'POST', form: { x: '1' } });
+ assert.strictEqual(remove.status, 303);
+ const after = await t.get('/agents', { as: ada });
+ assert.ok(after.text.includes('No provider keys yet'), 'the key is forgotten');
+ });
+
+ await check('Machines: the person’s own machines, their state, and Node’s pairing code flow', async () => {
+ const page = await t.get('/machines', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Desk'), 'the paired machine is listed');
+ assert.ok(page.text.includes('Online'), 'with its state');
+ assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
+ assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
+ assert.ok(page.text.includes('"appBar":false'), 'the workspace keeps the Frame\'s own navbar: no second bar');
+ const coded = await t.get('/machines?code=abcd-2345', { as: ada });
+ assert.ok(coded.text.includes('"appBar":false'), 'a query string does not bring the second bar back');
+ assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
+ const wrong = await t.get('/machines?code=nope', { as: ada });
+ assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
+ const revoke = await t.get(`/machines/${NODE}/revoke`, { as: ada, method: 'POST', form: { x: '1' } });
+ assert.strictEqual(revoke.status, 303);
+ const after = await t.get('/machines', { as: ada });
+ assert.ok(after.text.includes('Revoked'), 'the machine is revoked');
+ // Bo cannot revoke Ada's machine.
+ t.network.addNode(ada.subject, { principal: 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAA', node_id: 'node-other', name: 'Other', status: 'active', last_seen_at: new Date().toISOString() });
+ const stolen = await t.get('/machines/nod_01ARZ3NDEKTSV4RRFFQ69G5FAA/revoke', { as: bo, method: 'POST', form: { x: '1' } });
+ assert.strictEqual(stolen.status, 303);
+ assert.ok(String(stolen.headers.get('location')).includes('error='), 'another person’s machine is refused');
+ });
+
+ await check('Settings are remembered per person', async () => {
+ const save = await t.get('/settings', { as: ada, method: 'POST', form: { default_agent: 'codex', default_machine: NODE, notify_needs: '1', safety: 'project' } });
+ assert.strictEqual(save.status, 303);
+ assert.ok(String(save.headers.get('location')).includes('ok='));
+ const page = await t.get('/settings', { as: ada });
+ assert.ok(page.text.includes('<option value="codex" selected'), 'the default agent is kept');
+ assert.ok(page.text.includes('value="project" checked'), 'so is the safety level');
+ assert.ok(!page.text.includes('name="notify_done" value="1" checked'), 'an unticked box stays unticked');
+ const other = await t.get('/settings', { as: bo });
+ assert.ok(other.text.includes('<option value="codex">'), 'and it is only this person’s');
+ assert.ok(!other.text.includes('<option value="codex" selected'));
+ const bad = await t.get('/settings', { as: ada, method: 'POST', form: { safety: 'anything' } });
+ assert.ok(String(bad.headers.get('location')).includes('error='), 'a level Codes does not know is refused');
+ });
+
+ await check('Usage counts runs, minutes and tokens per day, and shows no prices', async () => {
+ // The task page seeds attempt 1 for a task it knows; the machine's own numbers belong on it.
+ await t.get(`/tasks/${TASK}`, { as: ada });
+ await db.exec(`UPDATE codes_attempts SET tokens = 1200, started_at = now() - interval '2 hours', finished_at = now() - interval '90 minutes' WHERE task_id = $1 AND n = 1`, [TASK]);
+ const page = await t.get('/usage', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Runs') && page.text.includes('Minutes') && page.text.includes('Tokens'), 'the columns are there');
+ assert.ok(page.text.includes('1,200'), 'the tokens are counted');
+ assert.ok(page.text.includes('30 min'), 'and the minutes');
+ assert.ok(page.text.includes('No prices are shown here'), 'and it says why there is no money');
+ const boPage = await t.get('/usage', { as: bo });
+ assert.ok(!boPage.text.includes('1,200'), 'another person’s usage is not shown');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Codes' GitHub reader (C4b), offline: a pull request's state and the repository search, with a stand-in for GitHub.
+ * The page must never invent a state: what GitHub did not answer is a problem beside the link.
+ */
+const assert = require('assert');
+const { check, done } = require('./helpers/boot');
+const { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord } = require('../server/domain/github');
+
+const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+
+(async () => {
+ await check('a repository URL and a pull request URL are read only when they are GitHub ones', async () => {
+ assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes'), { owner: 'OpenVibers', name: 'OpenVibe.Codes', url: 'https://github.com/OpenVibers/OpenVibe.Codes' });
+ assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);
+ assert.strictEqual(githubRepo('javascript:alert(1)'), null);
+ assert.deepStrictEqual(pullRequestParts('https://github.com/o/n/pull/12'), { owner: 'o', name: 'n', number: 12 });
+ assert.strictEqual(pullRequestParts('https://evil.example/o/n/pull/12'), null);
+ assert.strictEqual(ciWord({ state: 'success' }), 'Passed');
+ assert.strictEqual(mergeWord({ merged: true }), 'Merged');
+ assert.strictEqual(mergeWord({ state: 'open', mergeable: false }), 'Has conflicts');
+ });
+
+ await check('a pull request shows GitHub’s own CI and merge state, and is cached', async () => {
+ const calls = [];
+ const fetchImpl = async (url) => {
+ calls.push(url);
+ if (url.endsWith('/pulls/12')) return json(200, { state: 'open', merged: false, mergeable: true, mergeable_state: 'clean', title: 'Make it read well', head: { sha: 'abcdef1234', ref: 'codes/x' }, base: { ref: 'main' } });
+ if (url.includes('/commits/abcdef1234/status')) return json(200, { state: 'success', statuses: [] });
+ return json(404, {});
+ };
+ const gh = createGitHub({ config: {}, fetchImpl, log: { warn() {} } });
+ const pr = await gh.pullRequest('https://github.com/o/n/pull/12');
+ assert.strictEqual(pr.state, 'open');
+ assert.strictEqual(pr.ci, 'Passed');
+ assert.strictEqual(pr.merge, 'Ready to merge');
+ assert.strictEqual(pr.branch, 'codes/x');
+ await gh.pullRequest('https://github.com/o/n/pull/12');
+ assert.strictEqual(calls.length, 2, 'the second read comes from the cache');
+ });
+
+ await check('a pull request GitHub does not answer is a problem, never a guessed state', async () => {
+ const gh = createGitHub({ config: {}, fetchImpl: async () => json(404, { message: 'Not Found' }), log: { warn() {} } });
+ const pr = await gh.pullRequest('https://github.com/o/private/pull/3');
+ assert.ok(pr.problem && /private|no such pull request/i.test(pr.problem.detail), 'the problem says why');
+ const broken = createGitHub({ config: {}, fetchImpl: async () => { throw new Error('offline'); }, log: { warn() {} } });
+ const down = await broken.pullRequest('https://github.com/o/n/pull/4');
+ assert.ok(down.problem && down.state === undefined, 'an unreachable GitHub has no state');
+ });
+
+ await check('the search puts the person’s own repositories first, then GitHub’s public answer', async () => {
+ const store = { db: { many: async () => [{ repo: 'https://github.com/OpenVibers/OpenVibe.Codes', at: 2 }, { repo: 'https://example.com/nope', at: 1 }] } };
+ const fetchImpl = async (url) => url.includes('/search/repositories')
+ ? json(200, { items: [{ full_name: 'OpenVibers/OpenVibe.Shared', html_url: 'https://github.com/OpenVibers/OpenVibe.Shared', private: false, stars: 3 },
+ { full_name: 'someone/secret', html_url: 'https://github.com/someone/secret', private: true }, { full_name: 'OpenVibers/OpenVibe.Codes', html_url: 'https://github.com/OpenVibers/OpenVibe.Codes' }] })
+ : json(404, {});
+ const gh = createGitHub({ config: {}, store, fetchImpl, log: { warn() {} } });
+ const out = await gh.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', 'openvibe');
+ assert.strictEqual(out.recent.length, 1, 'only GitHub repositories are kept from the history');
+ assert.deepStrictEqual(out.found.map((r) => r.full_name), ['OpenVibers/OpenVibe.Codes', 'OpenVibers/OpenVibe.Shared'], 'own first, no duplicates, no private repositories');
+ const failing = createGitHub({ config: {}, store, fetchImpl: async () => json(403, { message: 'rate limited' }), log: { warn() {} } });
+ const limited = await failing.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', 'openvibe');
+ assert.ok(limited.problem && limited.problem.status === 429, 'a limited search says so');
+ assert.deepStrictEqual(limited.found.map((r) => r.full_name), ['OpenVibers/OpenVibe.Codes'], 'and keeps what Codes already knows');
+ const quiet = await gh.search('usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', '');
+ assert.ok(quiet.found.length >= 1 && !quiet.problem, 'an empty query spends no GitHub request');
+ });
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -57,6 +57,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -57,6 +57,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
vault: new Map(), // OpenVibe.AI's stand-in: a person's own keys, by subject and provider
vault: new Map(), // OpenVibe.AI's stand-in: a person's own keys, by subject and provider
+ nodes: new Map(), // Network's node principals (a person's machines), by principal id
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -341,6 +342,23 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -341,6 +342,23 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }
? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }
: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });
: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });
}
}
+ // ── Network's node principals (Codes C4b): a person's own machines ──
+ if (url.pathname === '/api/v1/me/nodes' || url.pathname.startsWith('/api/v1/me/nodes/')) {
+ const u = userFrom(req);
+ if (!u) return problem(json, 401, 'auth.required', 'sign in to see your machines');
+ if (req.method === 'GET' && url.pathname === '/api/v1/me/nodes') {
+ const mine = [...st.nodes.values()].filter((n) => n.owner === u.subject).map((n) => ({ ...n.view }));
+ return json(200, { nodes: mine });
+ }
+ const m = /^\/api\/v1\/me\/nodes\/([^/]+)\/revoke$/.exec(url.pathname);
+ if (req.method === 'POST' && m) {
+ const node = st.nodes.get(decodeURIComponent(m[1]));
+ if (!node || node.owner !== u.subject) return problem(json, 404, 'registry.unknown_node', 'no such machine of yours');
+ node.view = { ...node.view, status: 'revoked', revoked_at: new Date().toISOString() };
+ return json(200, node.view);
+ }
+ return problem(json, 404, 'route.not_found', 'no such route');
+ }
if (url.pathname.startsWith('/api/v1/projects')) {
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
@@ -363,6 +381,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -363,6 +381,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
setUsage(projectId, body) { st.usage.set(projectId, body); },
setUsage(projectId, body) { st.usage.set(projectId, body); },
setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
setCatalog(list) { st.catalog = list; },
setCatalog(list) { st.catalog = list; },
+ addNode(subject, view) { st.nodes.set(view.principal, { owner: subject, view }); return view; },
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
/** A Network client-credentials token: a first-party service (svc:<id>) or an app (app:<id>), for audience, holding cap. */
/** A Network client-credentials token: a first-party service (svc:<id>) or an app (app:<id>), for audience, holding cap. */
@@ -15,11 +15,11 @@ const BUDGETS = {
@@ -15,11 +15,11 @@ const BUDGETS = {
htmlRawKB: 42, // 40.4 with the ten harnesses the catalogue holds (2026-10-30: MiniMax Code and Claude Code on another provider joined it, and the home lists every one); 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)
htmlRawKB: 42, // 40.4 with the ten harnesses the catalogue holds (2026-10-30: MiniMax Code and Claude Code on another provider joined it, and the home lists every one); 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)
htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7
htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7
jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.
jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.
- jsRawKB: 317, // 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
- jsBrotliKB: 78, // 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
+ jsRawKB: 330, // 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
+ jsBrotliKB: 82, // 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
- cssRawKB: 50, // 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
- cssBrotliKB: 11, // 9.9 KB after adding the C4 workspace styles; 1.1 KB headroom for compression drift.
+ cssRawKB: 60, // 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
+ cssBrotliKB: 13, // 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};
@@ -51,6 +51,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -51,6 +51,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
+ 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
+ 'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
};
};