@galda/cli 0.10.119 → 0.10.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +236 -2
- package/docs/SECURITY.md +8 -8
- package/engine/analytics-client.mjs +30 -2
- package/engine/analytics-pref.mjs +67 -0
- package/engine/server.mjs +45 -1
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -674,6 +674,18 @@
|
|
|
674
674
|
.peratab.on{color:var(--ink);border-color:var(--ink3);background:rgba(var(--tint),.06)}
|
|
675
675
|
.peraask{display:flex;gap:8px;align-items:center;padding:10px 16px 6px}
|
|
676
676
|
.peraask input{flex:1;background:var(--glass);border:1px solid var(--hair2);border-radius:10px;color:var(--ink);font-size:12.5px;padding:8px 12px;outline:none}
|
|
677
|
+
/* First-run nudge + the npx answer. Deliberately not a painted panel or a
|
|
678
|
+
boxed card: a box reads as "tappable" (DESIGN-RULES §5.6). This is a line
|
|
679
|
+
of text and a stroke pointing into the input it is about. */
|
|
680
|
+
.cnudge{display:none;align-items:center;justify-content:center;gap:10px;margin:14px auto 0;opacity:0;transform:translateY(-3px);
|
|
681
|
+
transition:opacity .28s ease,transform .28s ease}
|
|
682
|
+
.cnudge.show{display:flex;opacity:1;transform:none}
|
|
683
|
+
.cnudge-t{font-size:12.5px;line-height:1.5;color:var(--ink2)}
|
|
684
|
+
.cnudge-a{width:12px;height:20px;flex:0 0 auto;border-left:1.5px solid var(--ink3);border-bottom:1.5px solid var(--ink3);
|
|
685
|
+
border-bottom-left-radius:7px;position:relative;opacity:.85}
|
|
686
|
+
.cnudge-a:after{content:'';position:absolute;right:-1px;bottom:-4px;width:6px;height:6px;
|
|
687
|
+
border-right:1.5px solid var(--ink3);border-bottom:1.5px solid var(--ink3);transform:rotate(-45deg)}
|
|
688
|
+
padding:10px 12px;font-family:var(--mono);font-size:12.5px;color:var(--ink);overflow-x:auto;white-space:nowrap}
|
|
677
689
|
.peraanswer{margin:0 16px 8px;font-size:12.5px;line-height:1.55;color:var(--ink2);white-space:pre-wrap;background:rgba(var(--tint),.03);border:1px solid var(--hair2);border-radius:10px;padding:10px 12px}
|
|
678
690
|
.peraanswer.loading{color:var(--ink3);font-style:italic}
|
|
679
691
|
.peraanswer.error{color:var(--danger)}
|
|
@@ -3283,6 +3295,13 @@
|
|
|
3283
3295
|
<div class="nowdoing" id="fsNowDoing" hidden></div>
|
|
3284
3296
|
<!-- Clawd HERO (§4.5 初期状態): filled by JS (CLAWD generator), shown via #fsRoot.clawdhero -->
|
|
3285
3297
|
<div id="fsClawdHero" aria-hidden="true"></div>
|
|
3298
|
+
<!-- Under the waiting hero, pointing down at the box a new person is meant
|
|
3299
|
+
to use. "CONNECTED / WAITING FOR GOALS" says our state; this says
|
|
3300
|
+
theirs. Shown only while the board has nothing on it. -->
|
|
3301
|
+
<div class="cnudge" id="composerNudge">
|
|
3302
|
+
<span class="cnudge-t">Type what you want done, down there.</span>
|
|
3303
|
+
<span class="cnudge-a" aria-hidden="true"></span>
|
|
3304
|
+
</div>
|
|
3286
3305
|
<!-- H23 mobile empty-state hero (flagship .m-hero) — chips fill the composer (#input) -->
|
|
3287
3306
|
<div class="m-hero"><h2>What should we build?</h2><p>Describe a goal in plain language. Claude Code runs it, checks its own work, and only calls it done with proof.</p><div class="m-chips"><button class="m-chip">Fix the failing test in lib.mjs</button><button class="m-chip">Add a token-usage counter</button><button class="m-chip">Summarize the open PRs</button></div></div>
|
|
3288
3307
|
<div class="fsstream"><div id="fsFeed"></div></div>
|
|
@@ -3474,6 +3493,20 @@
|
|
|
3474
3493
|
<div class="segbtn" id="langSeg"><button data-lang="auto">Auto</button><button data-lang="en">English</button><button data-lang="ja">日本語</button></div></div>
|
|
3475
3494
|
</div>
|
|
3476
3495
|
</div></div>
|
|
3496
|
+
<!-- "What is Galda? / What can it access?" — the orientation panel.
|
|
3497
|
+
WHY IT EXISTS: the first engineer we handed Galda to reached for the gear
|
|
3498
|
+
button before typing anything. He was not lost, he was CHECKING: people
|
|
3499
|
+
want to know what a tool can touch before they hand it a repository. This
|
|
3500
|
+
answers that where they already are, instead of sending them to a website.
|
|
3501
|
+
Rendered from a constant, entirely locally: NO agent run, NO tokens, no
|
|
3502
|
+
network. Spending someone's quota and 40 seconds to produce a text answer
|
|
3503
|
+
they could have read instantly would be a bad trade, and it would teach
|
|
3504
|
+
the wrong idea of what Galda is (it is not a chatbot).
|
|
3505
|
+
Same overlay/modal parts as Settings above — no new component. -->
|
|
3506
|
+
<div class="overlay revfloat" id="aboutOverlay"><div class="modal" style="width:min(560px,94vw)">
|
|
3507
|
+
<div class="perahead" style="border-bottom:1px solid var(--hair)"><div class="peratitle" id="aboutTitle">What is Galda?</div><span style="flex:1"></span><button class="mbtn ghost" id="aboutClose">Close</button></div>
|
|
3508
|
+
<div style="padding:16px;max-height:min(70vh,620px);overflow:auto" id="aboutBody"></div>
|
|
3509
|
+
</div></div>
|
|
3477
3510
|
<!-- Slack-style centered delete confirmation, replacing native confirm() for every goal
|
|
3478
3511
|
delete (restores a lost decision — see docs/confirmed-behaviors.md). Reusable via the
|
|
3479
3512
|
confirmDelete({title,preview}) -> Promise<boolean> helper. -->
|
|
@@ -6782,6 +6815,7 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
6782
6815
|
});
|
|
6783
6816
|
|
|
6784
6817
|
function render(){
|
|
6818
|
+
renderComposerNudge();
|
|
6785
6819
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
6786
6820
|
renderCtxBar(); // the folder above the composer follows the active project
|
|
6787
6821
|
renderFlagship();
|
|
@@ -9127,7 +9161,8 @@ function bootDisconnected(info){
|
|
|
9127
9161
|
background:rgba(4,4,6,.72);-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px)}
|
|
9128
9162
|
#connectGate.on{display:flex}
|
|
9129
9163
|
#connectGate .cg-card{position:relative;width:100%;max-width:440px;background:var(--panel,#0d0d0f);border:1px solid var(--hair2,rgba(255,255,255,.12));
|
|
9130
|
-
border-radius:18px;padding:
|
|
9164
|
+
border-radius:18px;padding:28px 26px;text-align:left;box-shadow:0 30px 80px -30px #000;
|
|
9165
|
+
max-height:calc(100vh - 44px);overflow:auto}
|
|
9131
9166
|
#connectGate h2{font:700 20px/1.3 var(--ui,system-ui);letter-spacing:-.02em;margin:0 0 8px;color:var(--ink,#e9e7e2)}
|
|
9132
9167
|
#connectGate p{font-size:13.5px;line-height:1.7;color:var(--ink2,#a3a097);margin:0 0 18px}
|
|
9133
9168
|
#connectGate .cg-cmd{display:flex;align-items:center;justify-content:space-between;gap:12px;background:#0a0a0b;
|
|
@@ -9139,6 +9174,14 @@ function bootDisconnected(info){
|
|
|
9139
9174
|
#connectGate .cg-hint{font-size:12px;line-height:1.6;color:var(--ink3,#6b6862);margin:0 0 16px}
|
|
9140
9175
|
#connectGate .cg-hint a{color:var(--blue,#8fb0ff);text-decoration:none}
|
|
9141
9176
|
#connectGate .cg-hint a:hover{text-decoration:underline}
|
|
9177
|
+
#connectGate .cg-trust{margin:18px 0 0;padding:16px 0 0;border-top:1px solid var(--hair2,rgba(255,255,255,.10))}
|
|
9178
|
+
#connectGate .cg-blanket{font-size:12.5px;line-height:1.6;color:var(--ink,#e9e7e2);font-weight:600;margin:0}
|
|
9179
|
+
#connectGate .cg-acchead{font:500 10px/1 var(--mono,monospace);letter-spacing:.09em;text-transform:uppercase;color:var(--ink3,#6b6862);margin:16px 0 6px}
|
|
9180
|
+
#connectGate .cg-accrow{font-size:12px;line-height:1.45;color:var(--ink2,#a3a097);padding:0 0 0 12px;margin:0 0 6px;position:relative}
|
|
9181
|
+
#connectGate .cg-accrow:before{content:'';position:absolute;left:0;top:7px;width:4px;height:1px;background:var(--ink3,#6b6862);opacity:.7}
|
|
9182
|
+
#connectGate .cg-seclink{margin:14px 0 0;font-size:12px}
|
|
9183
|
+
#connectGate .cg-seclink a{color:var(--blue,#8fb0ff);text-decoration:none}
|
|
9184
|
+
#connectGate .cg-acct{margin:18px 0 0;padding:16px 0 0;border-top:1px solid var(--hair2,rgba(255,255,255,.10))}
|
|
9142
9185
|
#connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
|
|
9143
9186
|
#connectGate .cg-who b{color:var(--ink2,#a3a097)}
|
|
9144
9187
|
#connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0;transition:color .2s}
|
|
@@ -9153,6 +9196,7 @@ function bootDisconnected(info){
|
|
|
9153
9196
|
#connectGate .cg-switch.escalated{color:var(--danger);font-weight:600}
|
|
9154
9197
|
#connectGate .cg-switch.escalated a{color:var(--danger);text-decoration:underline}
|
|
9155
9198
|
#connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
|
|
9199
|
+
#connectGate .cg-waitwrap{display:flex;justify-content:center}
|
|
9156
9200
|
#connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
|
|
9157
9201
|
#connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
|
|
9158
9202
|
@keyframes cgpulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green,#3DDC97) 50%,transparent)}50%{box-shadow:0 0 6px 2px color-mix(in srgb,var(--green,#3DDC97) 35%,transparent)}}
|
|
@@ -9172,9 +9216,21 @@ function bootDisconnected(info){
|
|
|
9172
9216
|
+ '<a href="https://claude.com/claude-code" target="_blank" rel="noreferrer">Claude Code</a> or '
|
|
9173
9217
|
+ '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
|
|
9174
9218
|
+ '— Galda uses whichever agent you have.</p>'
|
|
9219
|
+
// The gate said what to run and never what it would do. "npx? what else is
|
|
9220
|
+
// it going to touch?" is the actual reason people stop here, so the answer
|
|
9221
|
+
// sits with the command. Facts only, in step with docs/SECURITY.md.
|
|
9222
|
+
+ '<div class="cg-trust">'
|
|
9223
|
+
+ '<p class="cg-blanket">No admin access, no background service, nothing that starts on its own. '
|
|
9224
|
+
+ 'Plain JavaScript you can read before you run it, and one folder you can delete.</p>'
|
|
9225
|
+
+ '<div class="cg-acchead">What it can access</div>'
|
|
9226
|
+
+ NPX_ONBOARDING.access.map(function(t){ return '<div class="cg-accrow">' + esc(t) + '</div>'; }).join('')
|
|
9227
|
+
+ '<p class="cg-seclink"><a href="https://galda.app/security" target="_blank" rel="noreferrer">Security and permissions \u2192</a></p>'
|
|
9228
|
+
+ '</div>'
|
|
9229
|
+
+ '<div class="cg-acct">'
|
|
9175
9230
|
+ '<p class="cg-who">Signed in as <b></b></p>'
|
|
9176
9231
|
+ '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch Google account →</a></p>'
|
|
9177
|
-
+ '
|
|
9232
|
+
+ '</div>'
|
|
9233
|
+
+ '<div class="cg-waitwrap"><div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div></div>';
|
|
9178
9234
|
document.body.appendChild(ov);
|
|
9179
9235
|
ov.querySelector('.cg-who b').textContent = email || 'your account';
|
|
9180
9236
|
const open = () => ov.classList.add('on');
|
|
@@ -11961,6 +12017,13 @@ $('histBtn').onclick = openHistory;
|
|
|
11961
12017
|
$('setBtn').onclick = openSettings;
|
|
11962
12018
|
$('histClose').onclick = () => $('historyOverlay').classList.remove('show');
|
|
11963
12019
|
$('setClose').onclick = () => $('settingsOverlay').classList.remove('show');
|
|
12020
|
+
// About panel: Close, tapping the backdrop, and Esc all dismiss it (DESIGN-RULES
|
|
12021
|
+
// §設定画面 — a dialog must close when you click somewhere unrelated).
|
|
12022
|
+
$('aboutClose').onclick = () => closeAbout();
|
|
12023
|
+
$('aboutOverlay').onclick = (e) => { if (e.target === $('aboutOverlay')) closeAbout(); };
|
|
12024
|
+
document.addEventListener('keydown', (e) => {
|
|
12025
|
+
if (e.key === 'Escape' && $('aboutOverlay')?.classList.contains('show')) { e.stopPropagation(); closeAbout(); }
|
|
12026
|
+
}, true);
|
|
11964
12027
|
$('historyOverlay').addEventListener('click', (e) => { if (e.target === $('historyOverlay')) $('historyOverlay').classList.remove('show'); });
|
|
11965
12028
|
$('settingsOverlay').addEventListener('click', (e) => { if (e.target === $('settingsOverlay')) $('settingsOverlay').classList.remove('show'); });
|
|
11966
12029
|
for (const b of $('langSeg').querySelectorAll('button')) b.onclick = () => saveLang(b.dataset.lang);
|
|
@@ -12777,11 +12840,177 @@ function gearSetHTML(){
|
|
|
12777
12840
|
<button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
|
|
12778
12841
|
<button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
|
|
12779
12842
|
${billingRow}
|
|
12843
|
+
<div class="srow"><div class="sl">About Galda<span class="sd">What it is, what the agent can touch, and what leaves this machine</span></div>
|
|
12844
|
+
<button class="optchip" data-gabout="1">Open</button></div>
|
|
12845
|
+
${remoteAccessRow()}
|
|
12846
|
+
${shareUsageDataRow()}
|
|
12780
12847
|
${stability}
|
|
12781
12848
|
${gearRulesHTML()}
|
|
12782
12849
|
${hostReadinessRow()}
|
|
12783
12850
|
<div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
|
|
12784
12851
|
}
|
|
12852
|
+
// The npx moment, moved to where the intent is.
|
|
12853
|
+
//
|
|
12854
|
+
// A person who signed up on the web lands on a board with nothing behind it:
|
|
12855
|
+
// Galda drives the Claude Code / Codex on THEIR machine, and that is not
|
|
12856
|
+
// running yet. Until now the only thing they were told was "Galda is offline —
|
|
12857
|
+
// reconnecting…", which is true and useless: it describes our state, not their
|
|
12858
|
+
// next step. That is the "run npx" dead end.
|
|
12859
|
+
//
|
|
12860
|
+
// So the answer arrives when they reach for the composer, which is the moment
|
|
12861
|
+
// they have decided to do something. And it has to answer the real hesitation,
|
|
12862
|
+
// not just print a command: "npx? what else is it going to do to my machine?"
|
|
12863
|
+
//
|
|
12864
|
+
// Every line below is a fact about the shipped package, kept in step with
|
|
12865
|
+
// docs/SECURITY.md and the same block on galda.app. Do not promise here what
|
|
12866
|
+
// is not true there.
|
|
12867
|
+
const NPX_ONBOARDING = {
|
|
12868
|
+
why: 'This is a command, not a website, because Galda runs the Claude Code or Codex already installed on your machine, on your own subscription. A website cannot reach them.',
|
|
12869
|
+
blanket: 'No admin access, no background service, nothing that starts on its own. Plain JavaScript you can read before you run it, and one folder you can delete.',
|
|
12870
|
+
access: [
|
|
12871
|
+
'Your project files and tests, inside a throwaway copy',
|
|
12872
|
+
'Its git is read-only: no commit, no push, no checkout, no reset',
|
|
12873
|
+
'Never your working tree, your branch, or your uncommitted changes',
|
|
12874
|
+
'A fixed allowlist of commands, not arbitrary shell',
|
|
12875
|
+
'Nothing at all at install time',
|
|
12876
|
+
],
|
|
12877
|
+
};
|
|
12878
|
+
|
|
12879
|
+
// The nudge: with no agent behind the board, point at the composer instead of
|
|
12880
|
+
// leaving a new person to guess. Only while there is nothing to do here — it
|
|
12881
|
+
// disappears the moment an agent connects.
|
|
12882
|
+
function renderComposerNudge(){
|
|
12883
|
+
const n = $('composerNudge');
|
|
12884
|
+
if (!n) return;
|
|
12885
|
+
// Only while there is genuinely nothing here. The moment a first goal exists
|
|
12886
|
+
// the board explains itself and a standing instruction becomes clutter.
|
|
12887
|
+
const empty = !(state.goals || []).length && !(state.tasks || []).length;
|
|
12888
|
+
n.classList.toggle('show', empty);
|
|
12889
|
+
}
|
|
12890
|
+
|
|
12891
|
+
// The orientation panel's content. A constant, not a request: this renders with
|
|
12892
|
+
// zero tokens and no network, because the answer never depends on the project.
|
|
12893
|
+
//
|
|
12894
|
+
// Every line here is a fact about the shipped code, kept in step with
|
|
12895
|
+
// docs/SECURITY.md. Do not add a promise here that is not true there.
|
|
12896
|
+
const ABOUT_GALDA = [
|
|
12897
|
+
{ h: 'What Galda is', p: [
|
|
12898
|
+
'Galda hands your work to Claude Code or Codex and brings back something you can review.',
|
|
12899
|
+
'It has no AI of its own. It starts the agent already installed and signed in on this machine, on your own subscription, so there is no extra API cost — and your code is never uploaded to Galda to be run through a model.',
|
|
12900
|
+
] },
|
|
12901
|
+
{ h: 'What the agent can do', p: [
|
|
12902
|
+
'Read and edit files, and run tests, inside a private copy of your project.',
|
|
12903
|
+
'Run named commands only — language runtimes, package managers, test and lint runners, and read-only shell tools. Not arbitrary shell.',
|
|
12904
|
+
] },
|
|
12905
|
+
{ h: 'What the agent cannot do', p: [
|
|
12906
|
+
'It cannot commit, push, checkout, or reset. Its git access is read-only: status, log, diff, show, branch, blame, ls-files, rev-parse. Nothing else.',
|
|
12907
|
+
'It cannot touch your working tree. Every task runs in a throwaway git worktree, so your branch and your uncommitted changes are left alone.',
|
|
12908
|
+
] },
|
|
12909
|
+
{ h: 'What leaves this machine', p: [
|
|
12910
|
+
'Model traffic goes from Claude Code or Codex straight to Anthropic or OpenAI. Galda is not in that path.',
|
|
12911
|
+
'With remote access on, what this board displays passes through Galda’s relay so you can open it from your phone. It is encrypted in transit but not end to end, so treat Galda’s server as being in the path.',
|
|
12912
|
+
'Turn remote access off in Settings and no outbound connection is opened at all.',
|
|
12913
|
+
] },
|
|
12914
|
+
{ h: 'Removing it', p: [
|
|
12915
|
+
'Galda installs no background services and runs nothing at install time. Deleting ~/.manager-for-ai removes everything it keeps.',
|
|
12916
|
+
] },
|
|
12917
|
+
];
|
|
12918
|
+
function openAbout(){
|
|
12919
|
+
const body = $('aboutBody');
|
|
12920
|
+
if (body) {
|
|
12921
|
+
body.innerHTML = ABOUT_GALDA.map((s) => `
|
|
12922
|
+
<div class="setrow" style="display:block">
|
|
12923
|
+
<div class="setlabel">${esc(s.h)}</div>
|
|
12924
|
+
${s.p.map((t) => `<div class="setsub" style="margin-top:4px">${esc(t)}</div>`).join('')}
|
|
12925
|
+
</div>`).join('');
|
|
12926
|
+
}
|
|
12927
|
+
$('aboutOverlay')?.classList.add('show');
|
|
12928
|
+
}
|
|
12929
|
+
function closeAbout(){ $('aboutOverlay')?.classList.remove('show'); }
|
|
12930
|
+
// Reachable from the first-run composer link too, once that lands.
|
|
12931
|
+
window.openAbout = openAbout;
|
|
12932
|
+
|
|
12933
|
+
// Remote access = the relay. On, you can open this board from your phone; the
|
|
12934
|
+
// price is that what the board shows crosses Galda's relay server (it is a
|
|
12935
|
+
// reverse HTTP tunnel, TLS but not end-to-end encrypted — docs/SECURITY.md).
|
|
12936
|
+
// Off, no outbound socket is opened at all.
|
|
12937
|
+
//
|
|
12938
|
+
// This row exists because that switch already worked and nobody could find it:
|
|
12939
|
+
// it was the env var `RELAY_URL=`. The people who most need it are the ones who
|
|
12940
|
+
// will never read a source comment. Cursor puts Privacy Mode in Settings for
|
|
12941
|
+
// the same reason. Same shape as Language above — a server setting that applies
|
|
12942
|
+
// the moment it is chosen, not part of the visual edit session.
|
|
12943
|
+
//
|
|
12944
|
+
// No row at all when this build has no relay configured (§3 段階開示: never show
|
|
12945
|
+
// a switch that controls nothing).
|
|
12946
|
+
function remoteAccessRow(){
|
|
12947
|
+
const rp = state.relayPref;
|
|
12948
|
+
if (!rp || !rp.configured) return '';
|
|
12949
|
+
const on = rp.enabled === true;
|
|
12950
|
+
const desc = on
|
|
12951
|
+
? 'On — reach this board from your phone. What the board shows passes through Galda’s relay.'
|
|
12952
|
+
: 'Off — nothing leaves this machine. This board is not reachable from your phone.';
|
|
12953
|
+
return `<div class="srow"><div class="sl">Remote access<span class="sd">${esc(desc)}</span></div>
|
|
12954
|
+
<button class="optchip${on ? ' on' : ''}" data-grelay="on">On</button>
|
|
12955
|
+
<button class="optchip${on ? '' : ' on'}" data-grelay="off">Off</button></div>`;
|
|
12956
|
+
}
|
|
12957
|
+
// "Share usage data" — the usage-analytics switch (engine: /api/analytics-pref).
|
|
12958
|
+
//
|
|
12959
|
+
// The label is not a choice: data-controls.html tells people to look for
|
|
12960
|
+
// Settings → Privacy → "Share usage data", and until 2026-08-02 that string
|
|
12961
|
+
// appeared nowhere in this file. Renaming the row means editing that document
|
|
12962
|
+
// in the same breath.
|
|
12963
|
+
//
|
|
12964
|
+
// Sits directly under Remote access because they are the same kind of promise,
|
|
12965
|
+
// and because one overrides the other: with the relay off nothing is sent at
|
|
12966
|
+
// all (docs/SECURITY.md), so the chips are shown as off and out of service —
|
|
12967
|
+
// a switch that controls nothing must not look live (§3 段階開示).
|
|
12968
|
+
function shareUsageDataRow(){
|
|
12969
|
+
const ap = state.analyticsPref;
|
|
12970
|
+
if (!ap || !ap.configured) return ''; // nothing configured to send to
|
|
12971
|
+
const relayOff = ap.relayEnabled === false;
|
|
12972
|
+
const on = ap.enabled === true;
|
|
12973
|
+
const desc = relayOff
|
|
12974
|
+
? 'Off — remote access is off, so nothing is sent at all.'
|
|
12975
|
+
: on
|
|
12976
|
+
? 'On — a few product events (a task started, finished, failed). Never your prompts, code, or file paths.'
|
|
12977
|
+
: 'Off — no usage events are sent.';
|
|
12978
|
+
const dis = relayOff ? ' disabled' : '';
|
|
12979
|
+
return `<div class="srow"><div class="sl">Share usage data<span class="sd">${esc(desc)}</span></div>
|
|
12980
|
+
<button class="optchip${on ? ' on' : ''}" data-ganalytics="on"${dis}>On</button>
|
|
12981
|
+
<button class="optchip${on ? '' : ' on'}" data-ganalytics="off"${dis}>Off</button></div>`;
|
|
12982
|
+
}
|
|
12983
|
+
async function loadAnalyticsPref(){
|
|
12984
|
+
try { const r = await fetch(withKey('/api/analytics-pref')); state.analyticsPref = r.ok ? await r.json() : null; }
|
|
12985
|
+
catch { state.analyticsPref = null; }
|
|
12986
|
+
if ($('gearPop') && custTab === 'settings') buildGearPop();
|
|
12987
|
+
}
|
|
12988
|
+
async function saveAnalyticsPref(enabled){
|
|
12989
|
+
const r = await fetch(withKey('/api/analytics-pref'), {
|
|
12990
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
12991
|
+
body: JSON.stringify({ enabled }),
|
|
12992
|
+
});
|
|
12993
|
+
if (!r.ok) return showErr('Failed to save.');
|
|
12994
|
+
state.analyticsPref = await r.json();
|
|
12995
|
+
if ($('gearPop').classList.contains('open')) buildGearPop();
|
|
12996
|
+
}
|
|
12997
|
+
async function loadRelayPref(){
|
|
12998
|
+
try { const r = await fetch(withKey('/api/relay-pref')); state.relayPref = r.ok ? await r.json() : null; }
|
|
12999
|
+
catch { state.relayPref = null; }
|
|
13000
|
+
if ($('gearPop') && custTab === 'settings') buildGearPop();
|
|
13001
|
+
}
|
|
13002
|
+
async function saveRelayPref(enabled){
|
|
13003
|
+
const r = await fetch(withKey('/api/relay-pref'), {
|
|
13004
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
13005
|
+
body: JSON.stringify({ enabled }),
|
|
13006
|
+
});
|
|
13007
|
+
if (!r.ok) return showErr('Failed to save.');
|
|
13008
|
+
state.relayPref = await r.json();
|
|
13009
|
+
// Remote access decides whether anything is sent at all, so the row below it
|
|
13010
|
+
// is now saying something different — ask the engine rather than guess.
|
|
13011
|
+
await loadAnalyticsPref();
|
|
13012
|
+
if ($('gearPop').classList.contains('open')) buildGearPop();
|
|
13013
|
+
}
|
|
12785
13014
|
async function loadStability(){
|
|
12786
13015
|
try { const r = await fetch(withKey('/api/stability')); state.stability = r.ok ? await r.json() : null; }
|
|
12787
13016
|
catch { state.stability = null; }
|
|
@@ -12873,6 +13102,9 @@ function buildGearPop(){
|
|
|
12873
13102
|
pop.innerHTML = gearTabsHTML() + gearSetHTML();
|
|
12874
13103
|
wireGearCommon(pop);
|
|
12875
13104
|
for (const b of pop.querySelectorAll('[data-gslang]')) b.onclick = () => saveLang(b.dataset.gslang);
|
|
13105
|
+
for (const b of pop.querySelectorAll('[data-grelay]')) b.onclick = () => saveRelayPref(b.dataset.grelay === 'on');
|
|
13106
|
+
for (const b of pop.querySelectorAll('[data-ganalytics]')) b.onclick = () => saveAnalyticsPref(b.dataset.ganalytics === 'on');
|
|
13107
|
+
for (const b of pop.querySelectorAll('[data-gabout]')) b.onclick = () => openAbout();
|
|
12876
13108
|
const signBtn = pop.querySelector('[data-gsignin]');
|
|
12877
13109
|
if (signBtn) signBtn.onclick = async () => {
|
|
12878
13110
|
const msg = pop.querySelector('#licKeyMsg');
|
|
@@ -12898,6 +13130,8 @@ function buildGearPop(){
|
|
|
12898
13130
|
if (stabilityBtn) stabilityBtn.onclick = () => { state.stability = null; buildGearPop(); loadStability(); };
|
|
12899
13131
|
loadProjectRules();
|
|
12900
13132
|
if (!state.stability) loadStability();
|
|
13133
|
+
if (!state.relayPref) loadRelayPref();
|
|
13134
|
+
if (!state.analyticsPref) loadAnalyticsPref();
|
|
12901
13135
|
return;
|
|
12902
13136
|
}
|
|
12903
13137
|
if (custTab === 'mcp') {
|
package/docs/SECURITY.md
CHANGED
|
@@ -154,17 +154,17 @@ Each carries a timestamp and a pseudonymous account id, and nothing else.
|
|
|
154
154
|
- Repository names or paths
|
|
155
155
|
- Proof screenshots and recordings
|
|
156
156
|
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
usage would invalidate every other claim on this page, so it does not.
|
|
157
|
+
**Analytics do not follow the relay switch today.** Turning remote access off
|
|
158
|
+
stops the tunnel, but the product events above are still sent. If you want
|
|
159
|
+
zero analytics, start Galda with the endpoint unset:
|
|
160
|
+
`MANAGER_BILLING_API_URL= npx @galda/cli`. The client is a no-op without it
|
|
161
|
+
(`engine/analytics-client.mjs`). An in-app off switch is being implemented,
|
|
162
|
+
and this section will be updated when it ships.
|
|
164
163
|
|
|
165
164
|
## Reporting a vulnerability
|
|
166
165
|
|
|
167
|
-
|
|
166
|
+
Write to hello@galda.app. Please include what you did, what you saw, and the
|
|
167
|
+
version of Galda you were running.
|
|
168
168
|
|
|
169
169
|
## 日本語
|
|
170
170
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// (docs/DATA-RETENTION-PLAN.md). Posts a single Tier-A event to the billing-api
|
|
3
3
|
// /events endpoint.
|
|
4
4
|
//
|
|
5
|
+
// OFF SWITCH: `emitEvent` sends nothing when the person has turned "Share usage
|
|
6
|
+
// data" off, and nothing when remote access is off — see analytics-pref.mjs for
|
|
7
|
+
// why the second one is not optional.
|
|
8
|
+
//
|
|
5
9
|
// PRIVACY: NEVER pass prompt / task text / file paths. Only an event type, a
|
|
6
10
|
// user identifier (the endpoint hashes it into a pseudonymous id), and
|
|
7
11
|
// allow-listed non-sensitive meta (project_hash / plan / status / model /
|
|
@@ -18,16 +22,40 @@
|
|
|
18
22
|
// task_interrupted → engine, task.status = 'interrupted'
|
|
19
23
|
// review_approved / review_dismissed → on the review action
|
|
20
24
|
|
|
25
|
+
import { readAnalyticsPref, resolveAnalyticsEnabled } from './analytics-pref.mjs';
|
|
26
|
+
import { readRelayPref, resolveRelayEnabled } from './relay-pref.mjs';
|
|
27
|
+
|
|
21
28
|
const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
|
|
22
29
|
|
|
30
|
+
// Where the two preference files live, and what the relay is configured with.
|
|
31
|
+
// Supplied by whoever owns those (server.mjs at boot) rather than recomputed
|
|
32
|
+
// here: DATA_DIR has fallbacks this module has no business duplicating, and a
|
|
33
|
+
// second copy of that logic is how a switch ends up reading a file nobody writes.
|
|
34
|
+
let prefContext = { dataDir: null, relayUrl: process.env.RELAY_URL ?? '' };
|
|
35
|
+
export function setAnalyticsContext({ dataDir, relayUrl } = {}) {
|
|
36
|
+
prefContext = {
|
|
37
|
+
dataDir: dataDir ?? prefContext.dataDir,
|
|
38
|
+
relayUrl: relayUrl ?? prefContext.relayUrl,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Read at every event, not cached: a person who has just turned the switch off
|
|
43
|
+
// expects the next thing they do not to be reported, and a cache would make that
|
|
44
|
+
// depend on when the process last restarted. Events are rare (a task starting,
|
|
45
|
+
// a task finishing), so two small file reads cost nothing worth keeping.
|
|
23
46
|
export function analyticsEnabled() {
|
|
24
|
-
|
|
47
|
+
const { dataDir, relayUrl } = prefContext;
|
|
48
|
+
return resolveAnalyticsEnabled({
|
|
49
|
+
configured: Boolean(BILLING_API_URL),
|
|
50
|
+
pref: dataDir ? readAnalyticsPref(dataDir) : null,
|
|
51
|
+
relayEnabled: dataDir ? resolveRelayEnabled({ relayUrl, pref: readRelayPref(dataDir) }) : undefined,
|
|
52
|
+
});
|
|
25
53
|
}
|
|
26
54
|
|
|
27
55
|
// type: one of the EVENT_TYPES; uid: account email/id (hashed server-side);
|
|
28
56
|
// meta: optional allow-listed non-sensitive fields only.
|
|
29
57
|
export function emitEvent(type, uid, meta) {
|
|
30
|
-
if (!
|
|
58
|
+
if (!analyticsEnabled()) return; // unconfigured, switched off, or relay off — no-op
|
|
31
59
|
try {
|
|
32
60
|
fetch(`${BILLING_API_URL}/events`, {
|
|
33
61
|
method: 'POST',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Agent Manager — usage-analytics preference (the "Share usage data" switch)
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: two published documents already promised this switch and the
|
|
4
|
+
// product did not have it (found 2026-08-02).
|
|
5
|
+
//
|
|
6
|
+
// docs/SECURITY.md — "With the relay off, nothing is collected. Not reduced,
|
|
7
|
+
// not anonymized: nothing."
|
|
8
|
+
// data-controls.html — "Product telemetry … can be turned off completely in
|
|
9
|
+
// Settings → Privacy → 'Share usage data'."
|
|
10
|
+
//
|
|
11
|
+
// Neither was true. `emitEvent` was guarded by `if (!BILLING_API_URL) return`
|
|
12
|
+
// and nothing else: it never looked at the relay preference, and the setting the
|
|
13
|
+
// second document names by string did not exist anywhere in the app. A promise
|
|
14
|
+
// in a security document that the code does not keep is worse than no promise —
|
|
15
|
+
// it is the one thing a person cannot check for themselves.
|
|
16
|
+
//
|
|
17
|
+
// Deliberately the same shape as relay-pref.mjs (a file in DATA_DIR, read at
|
|
18
|
+
// use, default ON), because these two switches are read together and a person
|
|
19
|
+
// setting one will look for the other in the same place.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const ANALYTICS_PREF_FILE = 'analytics-pref.json';
|
|
25
|
+
|
|
26
|
+
export function analyticsPrefPath(dataDir) {
|
|
27
|
+
return join(dataDir, ANALYTICS_PREF_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Should a usage event be sent? Pure — no clock, no I/O, no env.
|
|
31
|
+
//
|
|
32
|
+
// configured : is there a billing/analytics endpoint at all (BILLING_API_URL)
|
|
33
|
+
// pref : the user's saved choice (true | false | null when never set)
|
|
34
|
+
// relayEnabled : is remote access on (resolveRelayEnabled)
|
|
35
|
+
//
|
|
36
|
+
// Default is ON: a fresh install reports the handful of Tier-A events described
|
|
37
|
+
// in docs/DATA-RETENTION-PLAN.md. Only an explicit `false` turns it off, so a
|
|
38
|
+
// corrupt or missing preference file can never silently disable it either way.
|
|
39
|
+
//
|
|
40
|
+
// Relay off wins over everything. SECURITY.md sells that mode as "nothing leaves
|
|
41
|
+
// your machine", so a usage event escaping it would invalidate every other claim
|
|
42
|
+
// on that page. Someone who turns off remote access has not opted into analytics
|
|
43
|
+
// by omission — they have said the opposite in the plainest way the product offers.
|
|
44
|
+
export function resolveAnalyticsEnabled({ configured, pref, relayEnabled } = {}) {
|
|
45
|
+
if (!configured) return false;
|
|
46
|
+
if (relayEnabled === false) return false;
|
|
47
|
+
return pref !== false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read the saved preference. Returns null when the user has never chosen, so
|
|
51
|
+
// callers can tell "unset" from "explicitly on".
|
|
52
|
+
export function readAnalyticsPref(dataDir) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(readFileSync(analyticsPrefPath(dataDir), 'utf8'));
|
|
55
|
+
return typeof raw?.enabled === 'boolean' ? raw.enabled : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // never set, unreadable, or corrupt — all mean "no choice made"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Save the preference. Throws on a real write failure so the endpoint can report
|
|
62
|
+
// it rather than telling the user it saved something it did not.
|
|
63
|
+
export function writeAnalyticsPref(dataDir, enabled) {
|
|
64
|
+
const value = enabled === true;
|
|
65
|
+
writeFileSync(analyticsPrefPath(dataDir), JSON.stringify({ enabled: value, at: Date.now() }) + '\n');
|
|
66
|
+
return value;
|
|
67
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
16
16
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
|
|
17
|
-
import { emitEvent } from './analytics-client.mjs';
|
|
17
|
+
import { emitEvent, setAnalyticsContext } from './analytics-client.mjs';
|
|
18
18
|
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
19
19
|
import { resolve, dirname, join, basename } from 'node:path';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
@@ -25,6 +25,7 @@ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refineP
|
|
|
25
25
|
import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
|
|
26
26
|
import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
|
|
27
27
|
import { readRelayPref, writeRelayPref, resolveRelayEnabled } from './relay-pref.mjs';
|
|
28
|
+
import { readAnalyticsPref, writeAnalyticsPref, resolveAnalyticsEnabled } from './analytics-pref.mjs';
|
|
28
29
|
import { createSerialQueue } from './lib.mjs';
|
|
29
30
|
import { wantsPullRequest } from './lib.mjs';
|
|
30
31
|
import { parseRequirementEvidence, unmappedChangedFiles, previewOpensSomethingElse } from './lib.mjs';
|
|
@@ -43,6 +44,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
43
44
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
44
45
|
?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
|
|
45
46
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
47
|
+
// The analytics client reads the "Share usage data" and remote-access
|
|
48
|
+
// preferences out of DATA_DIR before every event; it is told where that is
|
|
49
|
+
// rather than recomputing the fallbacks above.
|
|
50
|
+
setAnalyticsContext({ dataDir: DATA_DIR, relayUrl: process.env.RELAY_URL ?? '' });
|
|
46
51
|
// MANAGER_PORT=0 asks the OS for a free port; the real one is only known once
|
|
47
52
|
// the listen below succeeds, so this is rebound there (before anything prints
|
|
48
53
|
// or links to it).
|
|
@@ -4511,6 +4516,45 @@ const server = createServer(async (req, res) => {
|
|
|
4511
4516
|
});
|
|
4512
4517
|
return;
|
|
4513
4518
|
}
|
|
4519
|
+
// The "Share usage data" switch (docs/SECURITY.md, data-controls.html). Same
|
|
4520
|
+
// shape as /api/relay-pref above, because they are the same kind of promise and
|
|
4521
|
+
// the settings panel shows them together. `enabled` is what actually happens —
|
|
4522
|
+
// it is false when analytics are unconfigured OR remote access is off, whatever
|
|
4523
|
+
// this preference says, so the UI can never show "on" for something that sends
|
|
4524
|
+
// nothing.
|
|
4525
|
+
if (url.pathname === '/api/analytics-pref' && req.method === 'GET') {
|
|
4526
|
+
const pref = readAnalyticsPref(DATA_DIR);
|
|
4527
|
+
const relayEnabled = resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref: readRelayPref(DATA_DIR) });
|
|
4528
|
+
return json(res, 200, {
|
|
4529
|
+
pref,
|
|
4530
|
+
enabled: resolveAnalyticsEnabled({ configured: Boolean(BILLING_API_URL), pref, relayEnabled }),
|
|
4531
|
+
configured: Boolean(BILLING_API_URL),
|
|
4532
|
+
relayEnabled,
|
|
4533
|
+
});
|
|
4534
|
+
}
|
|
4535
|
+
if (url.pathname === '/api/analytics-pref' && req.method === 'POST') {
|
|
4536
|
+
let body = '';
|
|
4537
|
+
req.on('data', (d) => { body += d; });
|
|
4538
|
+
req.on('end', () => {
|
|
4539
|
+
let input;
|
|
4540
|
+
try { input = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
|
|
4541
|
+
// A boolean and nothing else — same reason as the relay switch: "off"/0/
|
|
4542
|
+
// undefined is how a privacy setting silently becomes the opposite of what
|
|
4543
|
+
// was clicked.
|
|
4544
|
+
if (typeof input?.enabled !== 'boolean') return json(res, 400, { error: 'enabled must be a boolean' });
|
|
4545
|
+
let saved;
|
|
4546
|
+
try { saved = writeAnalyticsPref(DATA_DIR, input.enabled); }
|
|
4547
|
+
catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
|
|
4548
|
+
const relayEnabled = resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref: readRelayPref(DATA_DIR) });
|
|
4549
|
+
return json(res, 200, {
|
|
4550
|
+
pref: saved,
|
|
4551
|
+
enabled: resolveAnalyticsEnabled({ configured: Boolean(BILLING_API_URL), pref: saved, relayEnabled }),
|
|
4552
|
+
configured: Boolean(BILLING_API_URL),
|
|
4553
|
+
relayEnabled,
|
|
4554
|
+
});
|
|
4555
|
+
});
|
|
4556
|
+
return;
|
|
4557
|
+
}
|
|
4514
4558
|
// POST /api/signout — forget the local license so the settings panel can
|
|
4515
4559
|
// offer a plain "Log out" instead of requiring the `--signin` CLI flag to
|
|
4516
4560
|
// switch Google accounts. Clears the on-disk token and the in-memory cache;
|
package/package.json
CHANGED