openvibers/openvibe.codes Commits

Codes C4b: the task page's own panels, and the pickers that remember (#178)

OP OpenVibers committed 14 days ago · cb16e6bf85 · 20 files, +1314 −38

* Codes C4b: the task page's own panels, and the pickers that remember

* Codes: the machine picker takes the order of use, not a spare argument

Changes

ddiff --git a/migrations/0013_user_settings.sql b/migrations/0013_user_settings.sql
nnew file mode 100644
iindex 0000000..bf95896
---- /dev/null
++++ b/migrations/0013_user_settings.sql
@@@ -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()
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 947d685..08777d2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 580c61d..c54124d 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -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}
++}
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex 1e04fd2..3ab54de 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -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);
++ });
++ });
})();
ddiff --git a/server/app.js b/server/app.js
iindex 5a40cb3..f4df389 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 0240cd6..2e6e66c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/domain/github.js b/server/domain/github.js
nnew file mode 100644
iindex 0000000..b599189
---- /dev/null
++++ b/server/domain/github.js
@@@ -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 };
ddiff --git a/server/domain/machines.js b/server/domain/machines.js
nnew file mode 100644
iindex 0000000..c5d2d35
---- /dev/null
++++ b/server/domain/machines.js
@@@ -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 };
ddiff --git a/server/domain/usage.js b/server/domain/usage.js
nnew file mode 100644
iindex 0000000..de44cb3
---- /dev/null
++++ b/server/domain/usage.js
@@@ -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 };
ddiff --git a/server/domain/user-settings.js b/server/domain/user-settings.js
nnew file mode 100644
iindex 0000000..10d1a4a
---- /dev/null
++++ b/server/domain/user-settings.js
@@@ -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 };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 2efb177..f4b4441 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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 },
};
/**
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 557762e..2c7645f 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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</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)}`;
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;
}
ddiff --git a/server/http/me-api.js b/server/http/me-api.js
iindex 0b4e955..0992465 100644
---- a/server/http/me-api.js
++++ b/server/http/me-api.js
@@@ -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);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4944518..e9adb9b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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 = {
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex 2bb4f2a..dc30420 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -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);
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
nnew file mode 100644
iindex 0000000..fc64539
---- /dev/null
++++ b/test/c4b.test.js
@@@ -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&rsquo;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&rsquo;s Changes, Proof and Pull request panels are Codes&rsquo; 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&rsquo;s own machines, their state, and Node&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;s usage is not shown');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/github.test.js b/test/github.test.js
nnew file mode 100644
iindex 0000000..268ca74
---- /dev/null
++++ b/test/github.test.js
@@@ -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&rsquo;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&rsquo;s own repositories first, then GitHub&rsquo;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); });
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 29066bd..b8fb96c 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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(),
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.m
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() }); },
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. */
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex f042acf..5924b1f 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.7
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.
-- 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
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 670de72..f425ed2 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
};