@galda/cli 0.10.1 → 0.10.5

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/CLAUDE.md CHANGED
@@ -33,6 +33,12 @@
33
33
  - 探索的な読み込み(横断 grep・場所探し・大きいファイル通読)は**既定で subagent に逃がし**、メイン文脈に全文を載せない。ピンポイントな1関数だけ直接読む。
34
34
  - 別エージェントへ渡す時は、チャット履歴でなく **1枚の handoff md** を書いて渡す。
35
35
 
36
+ ## デプロイ/インフラの掟(Masa明言 2026-07-15・全レーン必読)
37
+
38
+ - **`a2c-tech.workers.dev` を絶対に使わない・コードやdocsに書かない**。これはクライアント企業 **A2C** の名を冠した Cloudflare workers.dev サブドメインで、Galda の製品・URL・設定に一切登場させてはいけない(見つけたら全て除去)。
39
+ - Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
40
+ - **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
41
+
36
42
  ## 構成
37
43
 
38
44
  - `engine/server.mjs` … チャットアプリのサーバ(:4400・タスク分解・worker実行・SSE・PR作成)
package/app/index.html CHANGED
@@ -3,7 +3,8 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Agent Manager</title>
6
+ <title>Galda</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233B6BFF'/%3E%3Cstop offset='1' stop-color='%230E2AA8'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='url(%23g)' stroke-width='5'/%3E%3C/svg%3E">
7
8
  <!-- brand fonts FINAL (v45 §1): the 3 adopted faces self-host in /fonts.css — the old
8
9
  Google Fonts Inter/JetBrains request was removed (offline-safe, no stray families) -->
9
10
  <link rel="stylesheet" href="/theme.css">
@@ -1129,8 +1130,8 @@
1129
1130
  #fsRoot .fsproj.on i{background:var(--green)}
1130
1131
  /* running project = solid saturated green dot with a breathing glow — never dims to
1131
1132
  grey, independent of selection (HANDOFF-v45 §11, flagship .proj.run port) */
1132
- #fsRoot .fsproj.run i{background:#22c55e;box-shadow:0 0 0 0 rgba(34,197,94,.55);animation:fsrundot 2s ease-in-out infinite}
1133
- @keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,.5)}50%{box-shadow:0 0 5px 1.5px rgba(34,197,94,.35)}}
1133
+ #fsRoot .fsproj.run i{background:var(--green);box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 55%,transparent);animation:fsrundot 2s ease-in-out infinite}
1134
+ @keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 50%,transparent)}50%{box-shadow:0 0 5px 1.5px color-mix(in srgb,var(--green) 35%,transparent)}}
1134
1135
  @media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i{animation:none}}
1135
1136
  #fsRoot .fsproj .pname{min-width:0;overflow:hidden;text-overflow:ellipsis}
1136
1137
  #fsRoot .fsproj .num{margin-left:auto;font-size:10px;color:var(--ink3);flex:0 0 auto}
@@ -1168,7 +1169,7 @@
1168
1169
  .fsctxmenu button svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:1.8}
1169
1170
  .fsctxmenu .ctxsep{height:1px;margin:4px 6px;background:var(--hair)}
1170
1171
  .fsctxmenu .swix{opacity:.5;margin-right:9px;font:600 10px/1 var(--mono);flex:0 0 auto}
1171
- .fsctxmenu .swrun{margin-left:8px;width:6px;height:6px;border-radius:50%;background:#22c55e;flex:0 0 auto}
1172
+ .fsctxmenu .swrun{margin-left:8px;width:6px;height:6px;border-radius:50%;background:var(--green);flex:0 0 auto}
1172
1173
  #fsRoot .ghostbtn{height:26px;padding:0 8px;border:0;border-radius:7px;background:transparent;color:var(--ink3);font:inherit;font-size:11.5px;text-align:left;cursor:pointer;white-space:nowrap}
1173
1174
  #fsRoot .ghostbtn:hover{background:var(--hover);color:var(--ink)}
1174
1175
  #fsRoot .morebtn{display:grid;place-items:center;width:26px;padding:0}
@@ -1213,7 +1214,7 @@
1213
1214
  color:var(--ink3);background:transparent;border:0;padding:0;margin-left:10px;cursor:pointer}
1214
1215
  #fsRoot .chd .runsw[hidden]{display:none}
1215
1216
  #fsRoot .chd .runsw:hover{color:var(--ink)}
1216
- #fsRoot .chd .runsw i{width:5px;height:5px;border-radius:50%;background:#22c55e;flex:0 0 5px;animation:fsrundot 2s ease-in-out infinite}
1217
+ #fsRoot .chd .runsw i{width:5px;height:5px;border-radius:50%;background:var(--green);flex:0 0 5px;animation:fsrundot 2s ease-in-out infinite}
1217
1218
  #fsRoot .chd .runsw svg{width:9px;height:9px;flex:0 0 9px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
1218
1219
  #fsRoot .runmenu{display:none;flex-direction:column;gap:1px;background:var(--panel);border-radius:10px;
1219
1220
  box-shadow:0 4px 14px rgba(20,23,31,.12),0 0 0 1px var(--hair);padding:4px;position:absolute;top:22px;right:0;min-width:210px;z-index:6}
@@ -1222,7 +1223,7 @@
1222
1223
  color:var(--ink2);font:inherit;font-size:11.5px;text-align:left;cursor:pointer;white-space:nowrap;overflow:hidden}
1223
1224
  #fsRoot .runmenu .rmrow:hover{background:var(--hover);color:var(--ink)}
1224
1225
  #fsRoot .runmenu .rmrow.on{color:var(--ink);font-weight:600}
1225
- #fsRoot .runmenu .rmrow i{width:6px;height:6px;border-radius:50%;background:#22c55e;flex:0 0 6px;animation:fsrundot 2s ease-in-out infinite}
1226
+ #fsRoot .runmenu .rmrow i{width:6px;height:6px;border-radius:50%;background:var(--green);flex:0 0 6px;animation:fsrundot 2s ease-in-out infinite}
1226
1227
  #fsRoot .runmenu .rmrow .rmnm{overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}
1227
1228
  #fsRoot .runmenu .rmrow .rmpct{flex:0 0 auto;font:600 10px/1 var(--mono);color:var(--ink3)}
1228
1229
  /* "Now doing" — Underline pattern (HANDOFF-v45 §10 confirmed default, flagship
@@ -1232,7 +1233,7 @@
1232
1233
  max-width:var(--fscenterw,760px);width:100%;margin:-4px auto 14px;position:relative;z-index:1}
1233
1234
  #fsRoot .nowdoing[hidden]{display:none}
1234
1235
  #fsRoot .nowdoing .nd-lb{font:600 8.5px/1 var(--mono);letter-spacing:.12em;color:var(--ink3);text-transform:uppercase}
1235
- #fsRoot .nowdoing .nd-run{width:9px;height:9px;flex:0 0 9px;border-radius:50%;background:#22c55e;animation:fsrundot 2s ease-in-out infinite}
1236
+ #fsRoot .nowdoing .nd-run{width:9px;height:9px;flex:0 0 9px;border-radius:50%;background:var(--green);animation:fsrundot 2s ease-in-out infinite}
1236
1237
  #fsRoot .nowdoing .nd-no{font-size:12.5px;font-weight:600;color:var(--ink3);font-variant-numeric:tabular-nums}
1237
1238
  #fsRoot .nowdoing .nd-nm{font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto}
1238
1239
  #fsRoot .nowdoing .nd-pct{margin-left:auto;font:600 11px/1 var(--mono);color:var(--ink2);font-variant-numeric:tabular-nums;flex:0 0 auto}
@@ -1673,6 +1674,14 @@
1673
1674
  radial-gradient(80% 72% at 84% 7%,var(--lgA,rgba(216,228,255,.56)),transparent 62%),
1674
1675
  radial-gradient(120% 108% at 95% -5%,var(--lgB,rgba(150,170,220,.42)),transparent 74%),#07080a;
1675
1676
  background-attachment:fixed}
1677
+ /* light OFF (H17, flagship f9c75ad): kill the dominant top-right glow + every light layer →
1678
+ pure flat black (LP #111112). Overrides both the --glowc wash (any layout) and the flagship
1679
+ radial stack. Accent goes LP green #3DDC97 via var(--green) (set in theme.css). */
1680
+ body[data-theme="midnight"][data-light="off"],
1681
+ body[data-theme="midnight"][data-light="off"][data-layout="flagship"]{background:#111112 !important}
1682
+ /* send button on flat black: dark round + white arrow (was invisible white-on-white) */
1683
+ body[data-theme="midnight"][data-light="off"] .send{background:#17181c !important;box-shadow:inset 0 0 0 1px rgba(255,255,255,.16) !important}
1684
+ body[data-theme="midnight"][data-light="off"] .send svg{stroke:#fff !important}
1676
1685
  body[data-theme="midnight"] #fsRoot .fscard{background:rgba(10,11,16,.66);-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
1677
1686
  box-shadow:0 1px 0 rgba(255,255,255,.05) inset,0 0 0 1px var(--hair),0 24px 60px -24px rgba(0,0,0,.72)}
1678
1687
 
@@ -1908,6 +1917,9 @@
1908
1917
  #fsClawdHero{display:none;position:absolute;left:50%;top:38%;transform:translate(-50%,-50%);text-align:center;z-index:1;pointer-events:none}
1909
1918
  #fsClawdHero b{display:block;margin-top:14px;font:600 16px/1.3 'Schibsted Grotesk',-apple-system,system-ui,sans-serif;letter-spacing:-.012em;color:var(--ink)} /* hero wordmark = Schibsted (v45 §1) */
1910
1919
  #fsClawdHero span{display:block;margin-top:5px;font:500 10px/1.4 var(--mono);letter-spacing:.08em;color:var(--ink3)}
1920
+ /* Welcome P1 (H17 item 4, flagship f9c75ad): grounded hop + clean-cut eyes on settle + our CSS confetti.
1921
+ Two pixel frames (neutral black-eyes / laughing), no arm-raise, no official confetti. reduce-motion=static. */
1922
+ #fsClawdHero .cstage{position:relative;width:170px;height:132px;display:flex;align-items:flex-end;justify-content:center;margin:0 auto 2px}#fsClawdHero .cw{position:relative;width:150px;transform-origin:50% 100%;will-change:transform;animation:chHop 2.4s linear infinite}#fsClawdHero .cw img{position:absolute;left:0;bottom:0;width:100%;display:block;image-rendering:pixelated}#fsClawdHero .cw .chNeu{animation:chNeu 2.4s linear infinite}#fsClawdHero .cw .chLau{opacity:0;animation:chLau 2.4s linear infinite}#fsClawdHero .chConf{position:absolute;left:50%;top:34px;width:0;height:0;pointer-events:none;z-index:3}#fsClawdHero .chConf i{position:absolute;width:4px;height:7px;border-radius:1.5px;opacity:0;transform:translate(-50%,-50%);animation:chPop 2.4s cubic-bezier(.2,.7,.3,1) infinite}@keyframes chHop{0%,52%{transform:translateY(0) scale(1,1)}55%{transform:translateY(0) scale(1.08,.85)}58%{transform:translateY(-16px) scale(.95,1.14)}61%{transform:translateY(-27px) scale(.98,1.05)}64%{transform:translateY(-31px) scale(1,1)}67%{transform:translateY(-26px) scale(.98,1.05)}70%{transform:translateY(-14px) scale(.95,1.12)}73%{transform:translateY(0) scale(1.1,.82)}77%{transform:translateY(-4px) scale(.99,1.02)}80%,100%{transform:translateY(0) scale(1,1)}}@keyframes chLau{0%,53.8%{opacity:0}54%,85.8%{opacity:1}86%,100%{opacity:0}}@keyframes chNeu{0%,53.8%{opacity:1}54%,85.8%{opacity:0}86%,100%{opacity:1}}@keyframes chPop{0%,54%{opacity:0;transform:translate(0,6px) rotate(0) scale(.5)}60%{opacity:1;transform:translate(calc(var(--dx)*.5),calc(var(--dy)*.5)) rotate(calc(var(--rot)*.5)) scale(1)}72%{opacity:1;transform:translate(var(--dx),var(--dy)) rotate(var(--rot))}100%{opacity:0;transform:translate(calc(var(--dx)*1.1),calc(var(--dy) + 44px)) rotate(calc(var(--rot)*1.4)) scale(.9)}}@media (prefers-reduced-motion:reduce){#fsClawdHero .cw,#fsClawdHero .cw .chNeu,#fsClawdHero .cw .chLau{animation:none}#fsClawdHero .cw .chLau{opacity:0}#fsClawdHero .chConf{display:none}}
1911
1923
  #fsRoot.clawdhero #fsClawdHero{display:block}
1912
1924
  #fsRoot .cc-who{display:flex;align-items:center;gap:9px;padding:2px 0 6px}
1913
1925
  #fsRoot .cc-who .cs-n{font-size:12px;font-weight:600;color:var(--ink)}
@@ -2037,7 +2049,7 @@
2037
2049
  <canvas id="fx"></canvas>
2038
2050
  <div class="top">
2039
2051
  <div class="mark"></div>
2040
- <div class="brand">Agent Manager</div>
2052
+ <div class="brand">Galda</div>
2041
2053
  <div class="spacer"></div>
2042
2054
  <!-- Feedback 7: layout switcher — flip the arrangement live. Increment 1:
2043
2055
  現行 / タスク中央 / 3列カンバン (CSS reflow). 中央フロー・1列 = increment 2. -->
@@ -5187,7 +5199,7 @@ async function deleteProject(p){
5187
5199
  if (!p) return;
5188
5200
  if (!(await confirmDelete({
5189
5201
  title: 'Delete this project?',
5190
- body: 'This removes the project from Agent Manager. This cannot be undone.',
5202
+ body: 'This removes the project from Galda. This cannot be undone.',
5191
5203
  preview: p.name,
5192
5204
  }))) return;
5193
5205
  try {
@@ -5310,7 +5322,8 @@ function applyFsBand(){
5310
5322
  // Clawd HERO (§4.5 初期状態): big Clawd + "Claude Code / CONNECTED" in the
5311
5323
  // implementing column. Visibility is state-driven (renderFsFeed toggles
5312
5324
  // #fsRoot.clawdhero when the column has zero activity — no timers).
5313
- $('fsClawdHero').innerHTML = CLAWD(88) + '<b>Claude Code</b><span>CONNECTED · WAITING FOR GOALS</span>';
5325
+ $('fsClawdHero').innerHTML = '<div class="cstage"><div class="chConf"></div><div class="cw"><img class="chNeu" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARgAAAETCAYAAADtSRlPAAAMdElEQVR4nO3dPYwk6VkH8Keqq2f2E4uzzydzCToCH8JCIiAxgY1EDMIBSByS5cQkthCCHCFBgGTJSBA5cGQnRDYJws6xxElA6C9ZBEjGt+e1rZvdnZmurkJvdfds9+581HTvTL9d/fsF+0xrd7tm3u5+3v+89RUBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMBDFtr8BLvaPn3+nfWvypPu6Lo1UzqpmVn80vh9f/MrXfa7mqsUX5Oe1hw/izTpiFNOYxiiqqoy6btQMx2E6OYnxqIr32/vbfttkRYPJWD1to4g2Ttv0aBr1ZNpFTjW/cRhHxGRaR0iaKwxHxqpREdHWcZBKRHQP1SzHISkLH6cXGZHME0x60zZt082U03a2aKbmNw5Jep1YpcFknmDSmzY1mW3P0KoEsw4NJmMSzO4lmLKcP6CjwWRMgtmd5FRHOft1trGHepkGkzlrMLtRq2gkmHNoMJmzBpNHQpFg1qPBZMwazO4lGFZpMBmzBrP9ZOI4mM04kndnEkzZayYdF2WU0UQ1X2vsU7uZt+j3/LtYmzbi8IWft5qP63J98e/r6L+d9L9n43jLb5LMaTC5J5jJ/DiYdjajTi+pqbk8Oj6Jnx09jdNmGlVZRd3Ul9eTp1Ed3otfPjyI1x/eufT5d7U+OT6N/338uPs5rxyPpXF5642PxsGo7LWdFHUcyfsyDWYgCWYcTYzLMr79kw/iq9/4VjwqUr6/+piMURQxjTb+5pNvx+996nfjwZOfdh+WpmdiyrWm8Ug/xy8evhHf/M634svvfi+ix5gUZRlt08TrEfH5P/nDeOeNu905RleNxyLBlCPHwSyzBpOxdNBW371I3XEYo6WXM32Q0gcquaSm5nL2ODW1+VtiCHt1LnTJeKTmsnhcFqNoy9nX9iKtR4PJWDpoq+9xMN1ejOlsL8az9MfybN2zpmvOpOfp/vsA9uq8pM84LI1b006jSAs4PcbDcTDn02AGmGDupj/atov7nR5JZi8STJ9xWBq3lGAWJJj1aDADSzDVvfurcT/pm2S6RTkJ5mzc0tDMf0Xqm2BYpcEMLMHUT2eX2JRgNk8wy64a/+71cj2Yl2gwA1yD6UgwryTBnA3nFePfvV4SzEs0mIEcyXveXiRrMLeXYBZnU7PKiGROgtmNBGMv0vk0mMxJMNvbi7TMXqT1aDADOZvaGkweCYZVGswOrMEkN3Uk73JNNw9zHMxqglkcaNc3wbQ+Uis0mIwtz4jXPpI3ueaRvMneHweThqNpujFMR/IumrYEsx4nO2bqL3774+3Dg4Oo67L70C9myovO5q3bMqqyiDfvjOOPfu0j8d6DD/fazsHxpKtvvvZaV9NM3Gd7udduPIoy7p4+id9448PxmY+/1XvsT++M46NHP41PlHWcTNK5SVW0bXPl9tL9q8ajtnvtvvzu954fHr3HDMI1ffev/rSdtm2M0omCN1CXI/bJZPbhb+cvVJ+a3uzjahxF1yTKK+vCk3Ico5OnvbezKzWpilHcGRdRt0VURdvV9PMnadyXHy+ksUm3ha3b6dkHpc/20rbS3TirxQV3lvR5Pa7zuqWf5e0vfS3rz7AEc03pzTitm3jWTm9oBt78eaOezB9Pe9aIUTvJInncRG1jGkens8fH13wdkuslp9n/ezY57++nr6x2S0PjdMPavFmDuabZDOhOi7d9drQaK+MwP0UqexrMNc1itjstbvts6X2vzfN1+axpMNckwUgUOSSqUoIZJgkmjxl832sjwQyXNZjtz+D7XksJZni+8Ae/37b1xBpMBjP4vtdGghmmohpLMBnM4PteSwlmeP7pm//Svaz2Im1/Bt/32kgww2QvUh4z+L7XUoIZJnuR8pjB9702EswwSTB5zOD7XksJZpgkmDxm8H2vjQQzTBJMHjP4vtdSghkmCSaPGXzfayPBDJMEk8cMvu+1lGCGSYLJYwbf99pIMMMkweQxg+97LSWYYZJg8pjB9702EsxwOZt6+zP4vtdSghkeZ1Nvf+ZWQ4IZ8smOzqbOYwbf91pKMMPlbGpJYttJqrEGM0z2IuUxg+97LSWYYUo3R0t38EsOb7hac9jdpHTT748m3S64yD/GuKvANU3qiKN6dmvVm6on3c3C8pgp1euNQ8zri6/r6bR55e+T40n+DWZHgtZ++vpf/ln79uQX3ZrP5Ip7I6vbHYeUKFKy/c/il+Kz//AVn6s5CSZjk2nR3Yg+2lqSyDxRpdepaZsoy/xTxW3SYDI2Hj1/s257TUG9es2lLMpoGuFlmQaTsXrxS33kM1OrF6+9pATDKg0mY9ViapRgsk9QdZRdgmGVEcnY8owoOeSdoKzBnE+D2RHbnqHVfgnGGswqDWZHbHuGVvslmOVfa9Fgsrb8O70EsSMJxkLvimr1ITmxBrM7yWncJZhtv2Py41ekjEkwO5hgHAezQoPJmONgtp9M7EXajAaTseXDzrc9Q6sSzDo0mIwtx+1tz9Cq42DWocFkzJG8u5OcrMGcT4PJmL1Iu5OcHMl7Pg0mY/YibT+ZSDCb0WAyJsFsP5lIMJvRYDImwWw/mUgwm9FgMuY4mO0nEwlmMxpMxhwHs/1kIsFsRoPJmONgtp9MJJjNaDAZcxzM9pOJBLMZDSZj9iJtP5lIMJvRYDJmL9L2k4kEsxkNJmP2Im0/mUgwm3HBqdzXYObX/V7MpOlNf1lNFz26Mx5HW0+iqGa1rEbR1NNza123MapGcTKZRFWMom6nvbaz+PCNilEclG1MmyZGZdnVy7a3Tl08b6pPp0233b7j0R3GX4yiiNnPedV4LGobZdR1041Hn+3Vbdnd2dH1YFZpMANZg0lv8uTHx5P4nx/8KJrxg5hEE+NLQuri71P99dcexK986EO9Z+wklf/68ftx9OxZtMXNvpWKto4Hd+/Gb33sI2cf+j7f5/TwXrz36L34wfvz5ymfRtvcu7Au3I/H8atvfixev3MYddv0vqJduXSzPDSYnVuDuSy53B2V8Z2jJv7uX/9j3m76+/tP/2Z773c+FQ8/eNR9WCZR9koGn/vnf7v2tjbx33/+Tlv0HI/0cxwd3I93v//D+Ot//+61v8+//ewft5+5F1E2Eafp+S65R7gEcz5rMANJMN09rDdQlaNZnT9PnwSTksQ2tLcwHsu6e053TeTiJONs6vNpMAPZi5SuR7KJukm/dtRnz3PV9pKUYG7b9IU1kV7jscH3OZnW8+2m18P1YK5LgxlYginX/DDtSoJJi8q3mWAWTb7P9rqks3SZUzSYwSWYZs0PfUowXR1ignkFTd4V7dYjwQzkOBgJRoLJkQYzkLOpN00wCxLMc9VIgtmUBrMjbjrBHB6Ou2oN5rl6ag1mUxpMxm5zDebkZNJVCeY5CWZzGkzGbnMv0mKRV4LZLMEsX2IDDSZrt5lgFiSYDRPM0qSABpO120wwCxLMhmswS5MCGkzWJJjtHgcjwWxOu82YBLP9BFONZ3vXJJj1aDAZk2AySDCpy1iDWZsGkzEJZvsJJl18KpFg1qPBZEyCeZkEs1s0mIxJMC+TYHaLBpMxCWb3EszyCapoMFmTYHYwwbgezAoJJmO3mWAWF5xyJO/SmIwi6snsHC3Xg1mPBpOx20wwzqY+nyvabUaDydhtJpgnz467KsGsckW7zWgwGbMGk8EajGvybkSDyVi3VyK9REXVK8G0ZXHjZ1Mn6f5Axbxuay/S6fwOj4vv58W6GLeNtxejs+dZXIP4ojoeVe7s+AINJnPpdqTR1mcfnsWH/LCIKOc3ATv7dxveVaBqZs+TGtuLz794vPh+un/3Cu891NfheNylmHtV1d1GpJqPy+LnX9R0E7r0/Y2b2W1H1pVui5t+3rSddPvZi2pqLonrwaxy69iMPf7gKB7Haff1SX0ah1UZJ3XT1XQTsLYcRdGcdm/y9PjewUH87Gj276/r6OQ0nj75II6fHXfPt9hOquV4FM1kurLd43Yad7ZwbaX/O3ra1bRzJ52HeFFN32/6vn9SNfHz0/WazLPJaTx6UpyN/2IczqtFM+3G7fFZrgIAAAAAAAAAAAAAAAAgevp/2dsONs8JxOUAAAAASUVORK5CYII=" alt=""><img class="chLau" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARgAAAETCAYAAADtSRlPAAAQEElEQVR4nO3dS4wsZ3XA8VOvfs2Mx3duLNkgY3yT6EpgJ4oEmwQJxUJZAMKgKGSFHIkFG4SEvGDFDiQ2CBZZICVZIRArsI0QIotYQVEkgpBBSmyuyTXBQdiWL/c1z+56fOhUdc1Uv2aqqqenvpr+/yzr3Jnpen3ddb7T9dVDBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVOVUngIX5kvP/L15fHQn/fcodqTjmYVRuV5XjkajU1/X5HS9TkeSeFh7ujLtsOx0+XbVma7jevJz05Mvf/t77Fdjfv4P2Ge768vDnYHEcYWJOv3Ky4mSSK5e2HSDLNSdruL6+Z2Lm87zRB52qk93mZFgLDcMI/FdP/3QG8cRx5iJKOJNvN4xUfW/O76Eptr0daer+vel5uvMtlvZ9qg6nUbP82U/isq8rWuDBGO5YnJR01Fksry5qL9f1HKb2t6q0+n7VKnSXBNu0yuAEuW666c9pCLa2Q76PmEWCablFQzRjnbQ9wmzSDCWo4JpTwWjB3kxiQRjOSqYdkTFMZhZJBjLUcG0IyoqmFkkGMtRwdhRoVDB1EOCsRwVjB0VChVMPSQYy1HBtGcUiWMws0gwlqOCsaNCYRSpHhKM5ahg7KhQqGDqIcFYLj9DtOkemsh5MHWQYCyXnyHadA9N5DyYOkgwlqOCaUcFpTgPZhYJxnJUMO2ooBSjSLNIMJZjFMmOCoUKph4STAssutkU0Z52yG8OhklcY26xrcGWbMhh+u/IJOI7LtHydthgl5pAgrGYI4ns9DuS94uxEfEcoo3tEDjZl4EtZ6vRz4xt+IpkMd9zJDGJuCaxYiciLm4HrWBcSdJOASdIMBbT5JLTsQr9MBPtbAdfkon3CxkSjMXccdmt9DCi9pREO9shEnfi/UKGFrFYpF3jWNM9NJEKpg4SjOXHYHJN99BEKpg6SDAWo4JpT+XEMZj5SDAWo4JpT+XEMZj5SDAWo4JpvjKhglkOCcZiVDDNVyZUMMshwViMCqb5yoQKZjkkGItRwTRfmVDBLIcEYzEqmOYrEyqY5ZBgLEYF03xlQgWzHBKMxahgmq9MqGCWQ4KxGBVM85UJFcxySDAWo4JpvjKhglkOCcZiVDDNVyZUMMshwaxZBaM7jQ09/mWLXIs0HwlmjSoY3/Embu9YJTZdIZwVVdfRe+iUi+e9XVyLNB/35G2JZSqWxIjE3YH8/JVfyj/95BWRTvm3fXBwV5760IfkE+/6IxmGYeOVwrztC/xAvnnzLfnPF/9NRptXyjfqKJLPffDP5U8evyad0UHaTnXXI0jvaDd+o3CMBNMSeU9Z/JpTJibj8v3I78q9g0P5ydt3RPRhYfqojRJx0xh5771dcZMr6XxCcWutx6piun2Okd/uhvLSvUj27r1dafvu7B9I5HfFHx4stX2RcaVDcpnBV6SWWObYwIz8caenxXwnzCfR7xYW3llPty82Rjr+eO8urnfJ6EfD43biGMz5IsG0xDLHBmbkjzs9LRojjjuetr+z9HqscvTGcxwJ4/HDXYrrXWY79WC63z1uJ47BnC8SzCUfRVqmgjHJeNrD20uvx6ormMAbf9svrnfZSiY9VkAFswokmEs4ijQ9CpTf7b7bDSpVMMc/93fESczJfFYwClNltCiYWg+tYEbR1Ne7sttZkG9fPv86o0iGXWoCB3kv2XORVDieLotuthfos5OHYRo9I5K4rriJWRjjcZLp6wSHtyVxt2UYZ/MdFpZz0RVLcfuy9XDFM87JMRhljHg6Rfr3xVErn1xYaKd8/nVGkRyHZyMVcdy7ouc//XEzOhrKquguq/3gRuDJlY2BBF65UQ39cPcCT777+m154Yc/ktvB5sR8O3v35Y/fc12eeeJa+vMoiaXjemn0XV+iJJqIStfjzY0defk//l2ee/2ODAJvZn2/8uG/kne945FsFMZx053z3EeJdBSsuyn/++vX5Ivf/7E4g8ltU3/zyAPyng98UHZ23z51u4pRX/eNl27Krddvyl6wMTG/nXBP/vaTfycf2cqerll2dOkoStKRqd1Qn/R4ksBWodPrytP/8pzV+zAVTEXXr2xKGPZk1fTjWaVC0B7USVzZC2N5ZS+RPbk/OUMdch7F8uQjVyUM86ddL+a5rsRJIg9t7shLwyN5Y29/7utGw8M0uuPHp553xaLblTs8Gslrw0RkOLVt6pEH5MmHduRqPz7+VTT+gM+LriYM35O98Ib8Yj8RMZPz1J92j0YS7GzIcDQqvb4935VHt2cT4CoEgf27L8dgKoqMPn3YTctz/aCuKurOWuUYR3oMwMvezvRrzdSxBv1aNNB5G6fU8g/j7FGo8Xh4enp+E1Hy81HO/5hM2VGw4fAoXdeRfv0zyfHXuEUx/xqk9Fyf4vzyUSjX8Y6Tcdn1jVf8uRgWo7G6eEmRYCrSk7pW0VMvOtZQZTQliRPxB+NSf/qksoKlRp/mnWeywgrmzFGw8fp0uz3xElN6NKjoUH9baK98FCoxcVrJlZlfE7ENSDAVaa+xip76PHp6rWCig5OvMmlPXDwvZKxu5ZDPb2EFI0mjFUz6/pQ8n6Wor78tJKtiBaNfE8vMr4nYBiQYSyuYOj29VjAqOyoiWU9c6JGPxn+vWzksPL/kHM4jOWs99GS4kxWZWv64gqm6Hul8I1f2nMUVTH6co+n312lpBWP/USJLK5jzHi1ZNuq1MHqWy2bgyTsDkTe9jpigI044SmN3d1ceGgQSR3Hp+enOql87dOft+64E/Y3j+WnsF7onrRyKFcx5bpfOV0/n7/c6c9dD9TY2Kq+HtsWDfVce9UXuj9srt3O0K/2gM3MMpun3OS7ENmhLIrTGfz/7KaNXFSszbkCbYhgncjDKdrp0HV1PnCQbWdHEuNnrVJqf2j8apVVbUT7f7X5WOax6u2S8bfvRyShRTtdj0OmkQ/pV5ztv23I6z47nWvG+OnNiNwjkia9+0+p9mAqmgs8+/TFjotDKCiaPjufKYDDQL0yFodkgHV2pu94P9ntT8zuZ7/ACt0+HgPX/eeuh2+fWmK8mXL0Kenoou+78qGAmWZ39bGR7BUNcn3botqCC4SBvRXoWzCpGS4i0g8coEk4STfOjCMT1boc2oIKpKL1a1vGpOKg4Gq8824AEc0nOgyGuXzu0AQmmIr3En2MwHDOyoYJrAxJMTTb0YMT1boc2IMHUZEMPRlzvdmgDEkxNNvRgxPVuhzYgwdRkQw9GXO92aAMSTEV6g2m9oM6GHoy43u3QBiSYivTqWhvvB0Ncv3ZoAxJMxYsdHT/gPBhLevB1j21Agqkov5rahh6MuN7t0AYkmAr+8fkXHL3DGWfy2tGDr3tsAxJMRZzJ23zPTRQqmMuKUaTme26ipEm2Dahgauh7bnoXtMEKo+ekDz+lxz7HimXV7Znv870gSN/HfuBNxEW/L/v3YvTHF97ajltmVvSvr/5OwnnP6jln28lIHrt2TXYkosc+h8pF5UlglRXFLePLb27clHvuyQ3Eywj0SZ4VPlf6+jZoSaG1nr717GfME+Gd9N9ln41MbKYd9LlQWl38zHlQ/uHr32C/GmtHGlxTUd71cu2T9cde0udSpfcJWn112yYkGIv5hSN5jJ7YPXqUPlnTYXeaRotYjAqmfRUMJpFgLEYF03xlQgWzHBKMxahgmq9MqGCWQ4KxGBVM85UJFcxySDAt0XQPTeQYTB0kmJZouocmMopUBwnGYhyDaU/lxCjSfCQYi3EMpj2VE+fBzEeCsVjxvIqme2giFUwdXOxoseKZoXlPWeaaGL0qV++857muxEkieptP/dn1PUmieCbq64ZJoueipjfTCpws+o4nsYkXRn2d8nxvYnmrjIdxIl1HZGTkeD3PitPtUTYqXU6+vae1e2SyK+ExiQRjsdu7e3JXovTfuqOcRW/l+Yv7Q/ncd35Q+aP+zx/9S6NXb3eOjipN98Kv/l+++uJPL2TXevav328+9qePVppm1BvIjZdvyGd/+F+V1vHz779urv3F++TP5PD4d8X3wLieOEl8HLXt1X3Zr7R+lx0JxnL7o5H4ri9RkiWaeYzjiGO0S/fl1UOv1nL+563fS//6k3Ll/t2Z+U7H7G/ZR+eikku+rKfe/XC6AvPWazpqu73hDuTmrXuVl/W1n95wvvDOx8z1nUH6c7H9s/lHM1GXtz/uEJAhwVguTy76IT5LHNdfzoOBI5t33zr+OU8gZrzY6dgUE3TECUfH7bEoKm234oHyOjxPZBjOtv/08vR9wiwO8lou3Ulc/7hyWBTznaGuu6ERrzAD7ZFF4oVRuc4SC6ypE2XLLtMeaXIu3PKiDk3aZdr/tApznZFgWlbBnNZjL0MrmLhQAp21PBN4EjfQaY/8LKldZAVTtv31dRs+lUwRCcZyec9YpsdexmwFc/ry9GtKk8pWMNu7ty6kgsmXtx9RyRSRYCyXf7e3oYKZPtBbfP1FK1tR/H5wdanlUMEsh3rOYluDLdkKQwm8noRxIoHnnhoHvi/bGyfDqlVs9vqy/fCj0i0c6J2e/zT9/UW7GngS9DZKtYfGIDmQ/wvqJeGt7Suy3Xck7GTbXmZ5N6uN8l96JBiLBZ6RQfrZTsTxssfVdhbEgScSuPUPvHa7gQRJlM4nXaLjpieRTS8nP3lNuQ2MKGkS1eV3SrSHtltna6v2svRwjz5CRu/2r5cClFneA4MHznFr24+vSBYrjoCUuRZGJabe15ajw/EJfeP55GeoTi8nNEmaWPRnPWv4oiVysvwy7ZGqmXR1wCrUXOr4XItUEwmmJcpczXseQ8f5fM5anmogv6SXM5S9uvlYzaSbLS8RMRFXU9dEgrkkV1MvW8HEEknknsznrOWpJUeAazHipDt72fYwehRgiaSbX3DK1dT1kGAsF3h+etGjXuDnmmQyysnv9UI719NHZ9TbmfRhpH6SPTxMzV1OYXl6LEb/u2i9wEmv+5nbHoWYtsc5PEbED4I0zm2HQtTl6UWSmMRBXssvdnzDHKQ71DBK0iFTHRl2A73AbnR8oZ3S32/1O3Jnr975KfvhUA72d+Xo8ChdXnY1dbZcXV4SxtL1T36/K7H0GqhgfrubjZKdXGg4Whh1PUNzR+6O6p2bchiO5NZumG5z3u7aDvNifsHjfR6WCgAAAAAAAAAAAAAAAAAAAAAAAABS1R8AHmjUK201McoAAAAASUVORK5CYII=" alt=""></div></div>' + '<b>Claude Code</b><span>CONNECTED · WAITING FOR GOALS</span>';
5326
+ (function(){var conf=document.querySelector('#fsClawdHero .chConf');if(!conf)return;if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches)return;var C=["#FF6B6B","#F86A5C","#FFD166","#3DDC97","#8FB0FF","#C77DFF","#ffffff"],N=16;for(var k=0;k<N;k++){var i=document.createElement('i');var ang=(-172+Math.random()*164)*Math.PI/180;var dist=50+Math.random()*94;var dx=Math.cos(ang)*dist,dy=Math.sin(ang)*dist-12;i.style.setProperty('--dx',dx.toFixed(1)+'px');i.style.setProperty('--dy',dy.toFixed(1)+'px');i.style.setProperty('--rot',(Math.random()*360-180|0)+'deg');i.style.animationDelay=(-(Math.random()*0.04)).toFixed(3)+'s';i.style.background=C[k%C.length];conf.appendChild(i);}})(); // Welcome P1 confetti (H17 item 4)
5314
5327
  // fold toggles = パネルアイコン Fill v2 (HANDOFF-v45 §4) — faithful port of flagship
5315
5328
  // tgSvg/tgUpdate at the adopted value (__tgv v=1). OPEN = pane side filled 14% +
5316
5329
  // divider; CLOSED = outward arrow pointing in the opening direction. No rotation.
@@ -5671,7 +5684,94 @@ function connectSSE(){
5671
5684
  };
5672
5685
  }
5673
5686
 
5687
+ // Signed in, but this device's engine isn't running yet. We're the REAL app
5688
+ // (served by the relay), just flagged disconnected: render the board, then gate
5689
+ // the composer so the first attempt to type explains the one-time device setup
5690
+ // (npx @galda/cli). Poll `/`; the moment the engine connects the relay proxies
5691
+ // the live app (flag gone) → reload into it. (Masa 2026-07-15: one product, not a
5692
+ // separate look-alike page.)
5693
+ function bootDisconnected(info){
5694
+ const email = (info && info.email) || '';
5695
+ document.body.classList.add('disconnected');
5696
+ document.body.setAttribute('data-layout', localStorage.getItem('layout') || 'flagship');
5697
+ applyTheme(); applyCust();
5698
+ // seed a minimal board so it renders like the real empty state
5699
+ state.projects = [{ id: '__onboard', name: 'Your project' }];
5700
+ state.active = '__onboard';
5701
+ state.billing = { licensed: false, email };
5702
+ try { render(); } catch (e) { console.warn('disconnected render', e); }
5703
+ const heroSpan = document.querySelector('#fsClawdHero span');
5704
+ if (heroSpan) heroSpan.textContent = 'CONNECT YOUR MACHINE TO BEGIN';
5705
+
5706
+ const style = document.createElement('style');
5707
+ style.textContent = `
5708
+ #connectGate{position:fixed;inset:0;z-index:1000;display:none;align-items:center;justify-content:center;padding:22px;
5709
+ background:rgba(4,4,6,.72);-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px)}
5710
+ #connectGate.on{display:flex}
5711
+ #connectGate .cg-card{position:relative;width:100%;max-width:440px;background:var(--panel,#0d0d0f);border:1px solid var(--hair2,rgba(255,255,255,.12));
5712
+ border-radius:18px;padding:30px 28px;text-align:center;box-shadow:0 30px 80px -30px #000}
5713
+ #connectGate h2{font:700 20px/1.3 var(--ui,system-ui);letter-spacing:-.02em;margin:0 0 8px;color:var(--ink,#e9e7e2)}
5714
+ #connectGate p{font-size:13.5px;line-height:1.7;color:var(--ink2,#a3a097);margin:0 0 18px}
5715
+ #connectGate .cg-cmd{display:flex;align-items:center;justify-content:space-between;gap:12px;background:#0a0a0b;
5716
+ border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:12px 14px;margin:0 0 16px}
5717
+ #connectGate .cg-cmd code{font:500 14px/1 var(--mono,monospace);color:#e9e7e2}
5718
+ #connectGate .cg-cmd button{font:500 11px/1 var(--mono,monospace);color:#a3a097;background:transparent;
5719
+ border:1px solid rgba(255,255,255,.18);border-radius:7px;padding:6px 9px;cursor:pointer}
5720
+ #connectGate .cg-cmd button:hover{color:#fff}
5721
+ #connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
5722
+ #connectGate .cg-who b{color:var(--ink2,#a3a097)}
5723
+ #connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
5724
+ #connectGate .cg-switch a{color:#8fb0ff;text-decoration:none}
5725
+ #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}
5726
+ #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}
5727
+ #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
5728
+ @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)}}
5729
+ body.disconnected .composer{cursor:pointer}`;
5730
+ document.head.appendChild(style);
5731
+
5732
+ const ov = document.createElement('div');
5733
+ ov.id = 'connectGate';
5734
+ ov.innerHTML = '<div class="cg-card"><button class="cg-x" aria-label="Close">×</button>'
5735
+ + '<h2>Connect this device to start</h2>'
5736
+ + '<p>Galda runs on your own machine. Run this where you use Claude Code or Codex — this board comes alive the moment it connects, no refresh needed:</p>'
5737
+ + '<div class="cg-cmd"><code>npx @galda/cli</code><button class="cg-copy">Copy</button></div>'
5738
+ + '<p class="cg-who">Signed in as <b></b></p>'
5739
+ + '<p class="cg-switch">Wrong account? <a href="/logout">Switch account →</a></p>'
5740
+ + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
5741
+ document.body.appendChild(ov);
5742
+ ov.querySelector('.cg-who b').textContent = email || 'your account';
5743
+ const open = () => ov.classList.add('on');
5744
+ const close = () => ov.classList.remove('on');
5745
+ ov.querySelector('.cg-x').onclick = close;
5746
+ ov.addEventListener('mousedown', (e) => { if (e.target === ov) close(); });
5747
+ ov.querySelector('.cg-copy').onclick = function(){ try { navigator.clipboard.writeText('npx @galda/cli'); } catch {} this.textContent = 'Copied'; setTimeout(() => { this.textContent = 'Copy'; }, 1400); };
5748
+
5749
+ // gate the composer: any attempt to type a goal explains the setup first
5750
+ const input = document.getElementById('input');
5751
+ const send = document.getElementById('send');
5752
+ const gate = (e) => { if (e) e.preventDefault(); open(); };
5753
+ if (input) { input.readOnly = true; input.addEventListener('focus', gate); input.addEventListener('mousedown', gate); input.addEventListener('keydown', gate); }
5754
+ if (send) send.addEventListener('click', gate);
5755
+ // Keep the agent / model / effort pickers live so Codex users can switch to
5756
+ // Codex (and see it's a first-class option) even before this device connects;
5757
+ // only typing a goal or hitting send opens the connect explainer. (Masa 2026-07-15)
5758
+ const cw = document.getElementById('composerWrap');
5759
+ if (cw) cw.addEventListener('mousedown', (e) => { if (e.target.closest('#appsel, #modelsel, #effortsel')) return; gate(e); });
5760
+
5761
+ // come alive automatically: when the relay stops flagging disconnected (the
5762
+ // engine connected → its live app is proxied), reload into the real board.
5763
+ setInterval(() => {
5764
+ fetch('/', { cache: 'no-store', credentials: 'same-origin' }).then((r) => r.text()).then((t) => {
5765
+ if (t.indexOf('__GALDA_DISCONNECTED') === -1) location.reload();
5766
+ }).catch(() => {});
5767
+ }, 4000);
5768
+ }
5769
+
5674
5770
  async function boot(){
5771
+ // Served by the relay when you're signed in but this device's engine isn't
5772
+ // connected yet (window.__GALDA_DISCONNECTED). Show the real board, but gate the
5773
+ // composer with a "connect this device" explainer on the first attempt to type.
5774
+ if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
5675
5775
  $('input').value = localStorage.getItem('draft') ?? '';
5676
5776
  await refresh();
5677
5777
  flushOutbox();
@@ -6934,7 +7034,7 @@ const cust = {
6934
7034
  ring: Number(localStorage.getItem('ringPal') ?? 1), // prism send-ring palette (0-16) — default = 2nd 全分光 (§6.5 確定)
6935
7035
  tban: Number(localStorage.getItem('tbAnim') ?? 5), // TB縁アニメ (0-9 + 10 none) — default = 5 パルス (§6.5 確定)
6936
7036
  tbc: Number(localStorage.getItem('tbColor') ?? 1), // TB縁アニメの色 (RINGS 0-16) — default = 2nd 全分光 (§6.5 確定)
6937
- glow: Number(localStorage.getItem('glowLight') ?? 0), // Midnight LIGHT preset (0-5, Moon default)
7037
+ glow: Number(localStorage.getItem('glowLight') ?? 6), // Midnight LIGHT preset (0-5, or 6="Off" flat black — Midnight default)
6938
7038
  hue: localStorage.getItem('lightHue') != null ? Number(localStorage.getItem('lightHue')) : null, // Midnight free hue (0-359, null = preset)
6939
7039
  bg3: Number(localStorage.getItem('bg3') ?? 0), // Banff BACKDROP (0 Lake / 1 Peak / 2-6 dark scenes / -1 upload)
6940
7040
  cine: Number(localStorage.getItem('cineText') ?? 3), // Cinema TEXT (0-4) — default 3 Below (v3 §6.5)
@@ -7194,14 +7294,21 @@ function applyCust(){
7194
7294
  // Midnight light: preset (Moon default) or the free hue slider (__light/__lighthue port)
7195
7295
  const setLg = (la, lb, lc, w) => { const s = document.body.style;
7196
7296
  s.setProperty('--lgA', la); s.setProperty('--lgB', lb); s.setProperty('--lgC', lc); s.setProperty('--lgW', w); };
7197
- if (cust.hue != null) {
7198
- const h = ((cust.hue | 0) % 360 + 360) % 360;
7199
- setLg(`hsla(${h},90%,79%,.60)`, `hsla(${(h + 18) % 360},78%,56%,.48)`, `hsla(${h},95%,87%,.40)`, '.44');
7200
- document.body.style.setProperty('--glowc', `hsl(${h},90%,79%)`); // legacy old-layout wash
7297
+ // light OFF (glow index 6) = pure flat black (LP #111112) via [data-light="off"] — a 3rd state
7298
+ // beyond preset/hue (flagship __light('off')). CSS handles the flat black + green accent.
7299
+ if (cust.hue == null && cust.glow === 6) {
7300
+ document.body.setAttribute('data-light', 'off');
7201
7301
  } else {
7202
- const L = LIGHTS[((cust.glow % 6) + 6) % 6];
7203
- setLg(L[1], L[2], L[3], L[4]);
7204
- document.body.style.setProperty('--glowc', LIGHT_SW[((cust.glow % 6) + 6) % 6]);
7302
+ document.body.removeAttribute('data-light');
7303
+ if (cust.hue != null) {
7304
+ const h = ((cust.hue | 0) % 360 + 360) % 360;
7305
+ setLg(`hsla(${h},90%,79%,.60)`, `hsla(${(h + 18) % 360},78%,56%,.48)`, `hsla(${h},95%,87%,.40)`, '.44');
7306
+ document.body.style.setProperty('--glowc', `hsl(${h},90%,79%)`); // legacy old-layout wash
7307
+ } else {
7308
+ const L = LIGHTS[((cust.glow % 6) + 6) % 6];
7309
+ setLg(L[1], L[2], L[3], L[4]);
7310
+ document.body.style.setProperty('--glowc', LIGHT_SW[((cust.glow % 6) + 6) % 6]);
7311
+ }
7205
7312
  }
7206
7313
  // Banff backdrop (v3 §6.5): photo / dark scene / uploaded image (bg3 = -1 → dataURL)
7207
7314
  const tbg = document.getElementById('themebg');
@@ -7244,11 +7351,13 @@ function gearHistHTML(){
7244
7351
  for (const tk of state.tasks) if (tk.goalId === g.id && tk.finishedAt) t = Math.max(t, Date.parse(tk.finishedAt) || 0);
7245
7352
  return t;
7246
7353
  };
7247
- const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', ''] };
7354
+ // History = everything you've sent, newest first (not only settled goals
7355
+ // that felt empty). Each row gets a status glyph; unknown states fall back.
7356
+ const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', '—'], failed: ['no', '✕'], interrupted: ['ar', '⏸'], review: ['rv', '●'], running: ['run', '…'], planning: ['run', '…'], stacked: ['ar', '·'], needsInput: ['rv', '?'] };
7248
7357
  const evs = (state.goals || [])
7249
- .filter((g) => g.projectId === state.active && MARK[g.status])
7358
+ .filter((g) => g.projectId === state.active)
7250
7359
  .map((g) => ({ g, ts: goalTs(g) })).sort((a, b) => b.ts - a.ts);
7251
- if (!evs.length) return '<div class="mcpnote">No decisions yet — approved, reverted and archived goals will appear here.</div>';
7360
+ if (!evs.length) return '<div class="mcpnote">No history yet — everything you send shows up here.</div>';
7252
7361
  const todayK = new Date().toDateString(), yestK = new Date(Date.now() - 864e5).toDateString();
7253
7362
  const cap = (ts) => { const k = new Date(ts).toDateString();
7254
7363
  return k === todayK ? 'TODAY' : k === yestK ? 'YESTERDAY'
@@ -7257,7 +7366,7 @@ function gearHistHTML(){
7257
7366
  for (const { g, ts } of evs) {
7258
7367
  const c = cap(ts);
7259
7368
  if (c !== last) { out += `<h4>${c}</h4>`; last = c; }
7260
- const [cls, m] = MARK[g.status];
7369
+ const [cls, m] = MARK[g.status] || ['ar', '·'];
7261
7370
  const hhmm = new Date(ts).toTimeString().slice(0, 5);
7262
7371
  // 1-line ellipsis + a dedicated More/Less button (HANDOFF-v45 §9 — replaces row-tap;
7263
7372
  // the button itself is hidden post-render for rows that don't overflow, see wiring
@@ -7386,7 +7495,8 @@ function buildGearPop(){
7386
7495
  `<button class="sw${cust.bg3 === i ? ' on' : ''}" data-gbg3="${i}" title="${n}" style="background:${i < 2 ? bg3Image(i) + ' center/cover' : bg3Image(i)}"></button>`).join('')
7387
7496
  + `<button class="optchip${cust.bg3 === -1 ? ' on' : ''}" data-gbg3up="1" title="Upload an image">+</button>`;
7388
7497
  // Midnight LIGHT: 6 presets (Moon default) + free HUE slider
7389
- const lightSw = LIGHTS.map((L, i) => `<button class="sw${cust.hue == null && cust.glow === i ? ' on' : ''}" data-gglow="${i}" title="${L[0]}" style="background:${LIGHT_SW[i]}"></button>`).join('');
7498
+ const offSw = `<button class="sw${cust.hue == null && cust.glow === 6 ? ' on' : ''}" data-gglow="6" title="Off" style="background:#111112"></button>`; // flat-black "Off" — Midnight default, leftmost
7499
+ const lightSw = offSw + LIGHTS.map((L, i) => `<button class="sw${cust.hue == null && cust.glow === i ? ' on' : ''}" data-gglow="${i}" title="${L[0]}" style="background:${LIGHT_SW[i]}"></button>`).join('');
7390
7500
  // Cinema SCENE (reuses the band presets + upload) + TEXT modes
7391
7501
  const sceneNames = ['Lake', 'Peak', 'Blue', 'Violet', 'Dusk', 'Jade', 'Midnight', 'Gold'];
7392
7502
  const sceneSw = FS_BANDS.map((v, i) =>
package/app/theme.css CHANGED
@@ -71,6 +71,10 @@ body[data-theme="dark"],body[data-theme="midnight"],body[data-theme="cinema"]{
71
71
  /* flagship surface tokens — flagship internal T5/T6 (dark) values */
72
72
  --hover:rgba(255,255,255,.06); --hover-s:rgba(255,255,255,.05); --surf:rgba(255,255,255,.04);
73
73
  }
74
+ /* Midnight · light OFF = pure flat black (LP #111112). data-light="off" (H17 port, flagship f9c75ad 2026-07-14).
75
+ Black-screen accent = LP green #3DDC97 — unifies PR pill / ✓ / token / LIVE / DOING / progress. Both --green
76
+ (var(--green) users) and --green-rgb (rgba(var(--green-rgb),.x) / color-mix users) are re-pointed. */
77
+ body[data-theme="midnight"][data-light="off"]{--green:#3DDC97;--green-rgb:61,220,151}
74
78
  /* Theme 4 — Glass: ARCHIVED (v3 §6.5 row 4 / §7 decision log 2026-07-07).
75
79
  Not selectable from any UI; the token block + WebGL pipeline stay dormant/intact
76
80
  so the archive can be revived from the e574 loop without re-porting.
@@ -29,6 +29,12 @@ if (claude.error || claude.status !== 0) {
29
29
  }
30
30
  say(`claude CLI: ${claude.stdout.trim()}`);
31
31
 
32
+ // 1b) codex CLI (optional alternate worker). Informational only — so Codex users
33
+ // see it's detected and supported, not just Claude Code. Galda can run workers on
34
+ // either; you pick per task (Claude Code / Codex) in the board's composer.
35
+ const codex = probe('codex', ['--version']);
36
+ if (!codex.error && codex.status === 0) say(`codex CLI: ${codex.stdout.trim()} (Codex)`);
37
+
32
38
  // 2) Chrome (for verification proof) — recommended; warn if missing
33
39
  const chromeCandidates = [
34
40
  process.env.CHROME_PATH,
@@ -57,7 +63,11 @@ process.env.MANAGER_OPEN_BROWSER = process.env.MANAGER_OPEN_BROWSER ?? '1';
57
63
  // and `npm start` from the repo stay env-driven and don't hit the live worker.
58
64
  // The license public key is already baked in engine/lib.mjs (offline verify).
59
65
  // Opt out with an empty value: `MANAGER_BILLING_API_URL= RELAY_URL= npx @galda/cli`.
60
- process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://agent-manager-billing-api.a2c-tech.workers.dev';
66
+ // Use the OWNED custom domain (galda.app), never the Cloudflare workers.dev
67
+ // subdomain — that subdomain is named after an unrelated client (a2c-tech) and
68
+ // must never appear in Galda's product. galda.app is bound to the same Worker
69
+ // and serves the whole API (/connect, /checkout, /api/*). See [[no-a2c-tech]].
70
+ process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://galda.app';
61
71
  process.env.RELAY_URL = process.env.RELAY_URL ?? 'wss://app.galda.app/agent';
62
72
  await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
63
73
 
@@ -0,0 +1,38 @@
1
+ // analytics-client.mjs — fire-and-forget client for Phase 1 usage analytics
2
+ // (docs/DATA-RETENTION-PLAN.md). Posts a single Tier-A event to the billing-api
3
+ // /events endpoint.
4
+ //
5
+ // PRIVACY: NEVER pass prompt / task text / file paths. Only an event type, a
6
+ // user identifier (the endpoint hashes it into a pseudonymous id), and
7
+ // allow-listed non-sensitive meta (project_hash / plan / status / model /
8
+ // source — see workers/billing-api/src/analytics.mjs sanitizeMeta). Failures
9
+ // are swallowed so analytics can never break a task run or block the caller.
10
+ //
11
+ // Hook points (wiring lives in engine/relay, kept out of this module):
12
+ // signup → engine, once per install when the MCP client (Claude
13
+ // Code / Codex) first connects = "setup completed"
14
+ // (maybeEmitSetupCompleted in server.mjs)
15
+ // task_created → engine, when a task/goal is accepted
16
+ // task_completed → engine, task.status = 'done'
17
+ // task_failed → engine, task.status = 'failed'
18
+ // task_interrupted → engine, task.status = 'interrupted'
19
+ // review_approved / review_dismissed → on the review action
20
+
21
+ const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
22
+
23
+ export function analyticsEnabled() {
24
+ return Boolean(BILLING_API_URL);
25
+ }
26
+
27
+ // type: one of the EVENT_TYPES; uid: account email/id (hashed server-side);
28
+ // meta: optional allow-listed non-sensitive fields only.
29
+ export function emitEvent(type, uid, meta) {
30
+ if (!BILLING_API_URL) return; // analytics off when unconfigured — no-op
31
+ try {
32
+ fetch(`${BILLING_API_URL}/events`, {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json' },
35
+ body: JSON.stringify({ type, uid, meta }),
36
+ }).catch(() => {}); // fire-and-forget: never surface network errors
37
+ } catch { /* never throw into the caller */ }
38
+ }
package/engine/lib.mjs CHANGED
@@ -2376,3 +2376,13 @@ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK
2376
2376
  }
2377
2377
  return { valid: true, reason: null, payload };
2378
2378
  }
2379
+
2380
+ // Setup-completion analytics: the `signup` event fires exactly ONCE per install,
2381
+ // the first time the MCP client (Claude Code / Codex) connects to the Manager.
2382
+ // Pure decision so it can be unit-tested without a live server or filesystem —
2383
+ // the caller supplies whether the request is from the MCP client, whether this
2384
+ // process already saw it this session, and whether the persisted once-ever flag
2385
+ // already exists on disk.
2386
+ export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists }) {
2387
+ return Boolean(isMcpClient) && !alreadySeen && !flagExists;
2388
+ }
package/engine/mcp.mjs CHANGED
@@ -24,7 +24,10 @@ const KEY = process.env.MANAGER_KEY
24
24
 
25
25
  async function api(path, opts = {}) {
26
26
  const sep = path.includes('?') ? '&' : '?';
27
- const r = await fetch(`${BASE}${path}${sep}key=${KEY}`, opts);
27
+ // Tag every MCP→Manager call so the server can detect "connected to Claude
28
+ // Code / Codex" (the setup-completed signal) — see maybeEmitSetupCompleted.
29
+ const headers = { ...(opts.headers || {}), 'x-manager-client': 'mcp' };
30
+ const r = await fetch(`${BASE}${path}${sep}key=${KEY}`, { ...opts, headers });
28
31
  if (!r.ok) throw new Error(`Manager server ${r.status}: ${(await r.text()).slice(0, 200)}`);
29
32
  return r.json();
30
33
  }
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // RELAY_URL=wss://relay.example/agent RELAY_AGENT_TOKEN=… node engine/relay-client.mjs
10
10
 
11
- import { readFileSync, existsSync } from 'node:fs';
11
+ import { readFileSync, existsSync, watch } from 'node:fs';
12
12
  import { resolve, dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
@@ -19,19 +19,40 @@ const DATA_DIR = process.env.MANAGER_HOME
19
19
  const LOCAL = process.env.MANAGER_LOCAL ?? `http://localhost:${process.env.MANAGER_PORT ?? 4400}`;
20
20
  const KEY = existsSync(join(DATA_DIR, 'secret.key')) ? readFileSync(join(DATA_DIR, 'secret.key'), 'utf8').trim() : '';
21
21
  const RELAY = process.env.RELAY_URL ?? 'ws://localhost:5500/agent';
22
+ const DEV_EMAIL = process.env.RELAY_DEV_EMAIL ?? '';
23
+ const LICENSE_FILE = join(DATA_DIR, 'license.token');
22
24
  // The relay (P3.1) authenticates the agent by its signed LICENSE TOKEN — only
23
25
  // the billing Worker can mint one for a verified email, so the relay binds this
24
26
  // socket to that email's id (no shared token, unspoofable). Falls back to an
25
27
  // explicit RELAY_AGENT_TOKEN, and to a dev email for local testing.
26
- const LICENSE = existsSync(join(DATA_DIR, 'license.token')) ? readFileSync(join(DATA_DIR, 'license.token'), 'utf8').trim() : '';
27
- const TOKEN = process.env.RELAY_AGENT_TOKEN ?? LICENSE;
28
- const DEV_EMAIL = process.env.RELAY_DEV_EMAIL ?? '';
28
+ // Read FRESH each connect (not once at load): on first run the agent starts with
29
+ // no token (bound to nobody); when the user signs in, license.token appears and
30
+ // the watcher below reconnects, so the relay re-binds this socket to the real
31
+ // identity and the user's hosted board comes alive — no CLI restart needed.
32
+ function readToken() {
33
+ const lic = existsSync(LICENSE_FILE) ? readFileSync(LICENSE_FILE, 'utf8').trim() : '';
34
+ return process.env.RELAY_AGENT_TOKEN ?? lic;
35
+ }
29
36
 
30
37
  const live = new Map(); // reqId -> AbortController
38
+ let currentWs = null;
39
+ let reconnectTimer = null;
40
+ let lastToken = readToken();
41
+
42
+ function scheduleReconnect(retryMs) {
43
+ if (reconnectTimer) return; // exactly one pending reconnect — never stack timers
44
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(Math.min(retryMs * 2, 15000)); }, retryMs);
45
+ }
31
46
 
32
47
  function connect(retryMs = 1000) {
33
- const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(TOKEN)}`;
48
+ // Single-socket invariant: never open a second socket while one is CONNECTING
49
+ // (0) or OPEN (1). Two live sockets with the same identity make the relay evict
50
+ // each other (server.mjs "replaced by a newer connection") → an infinite flap.
51
+ if (currentWs && (currentWs.readyState === 0 || currentWs.readyState === 1)) return;
52
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
53
+ const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(readToken())}`;
34
54
  const ws = new WebSocket(`${RELAY}?${auth}`);
55
+ currentWs = ws;
35
56
  ws.onopen = () => { retryMs = 1000; console.log(`[agent] connected to relay ${RELAY}`); };
36
57
  ws.onmessage = async (ev) => {
37
58
  let m; try { m = JSON.parse(ev.data); } catch { return; }
@@ -73,10 +94,35 @@ function connect(retryMs = 1000) {
73
94
  }
74
95
  };
75
96
  ws.onclose = () => {
97
+ if (ws !== currentWs) return; // a superseded socket (we already moved on) — don't reconnect
76
98
  console.log(`[agent] relay connection lost — retrying in ${retryMs}ms`);
77
- setTimeout(() => connect(Math.min(retryMs * 2, 15000)), retryMs);
99
+ scheduleReconnect(retryMs);
78
100
  };
79
101
  ws.onerror = () => { /* onclose follows */ };
80
102
  }
81
103
 
82
104
  connect();
105
+
106
+ // Sign-in reconnect: when license.token appears/changes (the user just linked
107
+ // this device), drop the current socket so connect() re-dials with the new
108
+ // identity. Without this the first-run socket stays bound to nobody and the
109
+ // hosted board never comes alive until the CLI is restarted. Watch the DIR (the
110
+ // file may not exist yet on first run); best-effort (fs.watch unsupported → the
111
+ // onclose retry loop still re-reads the token on the next natural reconnect).
112
+ try {
113
+ watch(DATA_DIR, (_ev, f) => {
114
+ if (f && f !== 'license.token') return;
115
+ const t = readToken();
116
+ if (t && t !== lastToken) {
117
+ lastToken = t;
118
+ console.log('[agent] signed in — reconnecting to relay with your identity');
119
+ // Detach the old socket FIRST so its onclose is ignored (ws !== currentWs),
120
+ // then reconnect once, immediately, with a fresh backoff — no racing chains.
121
+ const old = currentWs;
122
+ currentWs = null;
123
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
124
+ try { old?.close(); } catch { /* already gone */ }
125
+ connect(1000);
126
+ }
127
+ });
128
+ } catch { /* fs.watch unavailable on this platform */ }
package/engine/server.mjs CHANGED
@@ -14,12 +14,13 @@
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 } from 'node:fs';
17
- import { randomBytes } from 'node:crypto';
17
+ import { emitEvent } from './analytics-client.mjs';
18
+ import { randomBytes, createHash } from 'node:crypto';
18
19
  import { resolve, dirname, join, basename } from 'node:path';
19
20
  import { homedir } from 'node:os';
20
21
  import { fileURLToPath } from 'node:url';
21
22
  import { pathToFileURL } from 'node:url';
22
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken } from './lib.mjs';
23
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted } from './lib.mjs';
23
24
  import { openPR } from './pr.mjs';
24
25
  import { runVerification, exerciseUi } from './verify.mjs';
25
26
 
@@ -36,6 +37,24 @@ const PORT = Number(process.env.MANAGER_PORT ?? 4400);
36
37
  const keyFile = join(DATA_DIR, 'secret.key');
37
38
  if (!existsSync(keyFile)) writeFileSync(keyFile, randomBytes(18).toString('base64url'));
38
39
  const ACCESS_KEY = readFileSync(keyFile, 'utf8').trim();
40
+ // A stable, anonymous per-install id for usage analytics — a one-way hash of the
41
+ // access key so the raw credential NEVER leaves the machine (the billing Worker
42
+ // hashes this again into its pseudonymous id). Not the access key itself.
43
+ const ANALYTICS_UID = createHash('sha256').update('galda-analytics:' + ACCESS_KEY).digest('hex').slice(0, 24);
44
+ // "Setup completed" = npx-installed AND connected to Claude Code / Codex. That
45
+ // connection is the MCP client (engine/mcp.mjs) first reaching this server, so
46
+ // we fire the `signup` event once, the first time an MCP-tagged request arrives,
47
+ // and persist a flag so it counts once per install ever (not once per restart).
48
+ const SETUP_FLAG = join(DATA_DIR, 'setup-completed.flag');
49
+ let _setupClientSeen = false;
50
+ function maybeEmitSetupCompleted(req) {
51
+ if (_setupClientSeen) return; // already decided this session — no repeat disk hit
52
+ if ((req.headers['x-manager-client'] || '') !== 'mcp') return;
53
+ _setupClientSeen = true;
54
+ if (!shouldEmitSetupCompleted({ isMcpClient: true, alreadySeen: false, flagExists: existsSync(SETUP_FLAG) })) return;
55
+ try { writeFileSync(SETUP_FLAG, String(Date.now())); } catch { /* best effort — still emit */ }
56
+ emitEvent('signup', ANALYTICS_UID, { source: 'mcp-connect' });
57
+ }
39
58
  // Per-worker run cap. Env-configurable so it can be tuned operationally and the
40
59
  // timeout→'interrupted' path is testable (a test sets a short value + a slow
41
60
  // fake worker instead of waiting 10 real minutes).
@@ -541,6 +560,56 @@ function saveTask(t) {
541
560
  appendFileSync(logFile, JSON.stringify({ ...persist, kind: 'task' }) + '\n');
542
561
  const goal = goals.find((g) => g.id === t.goalId);
543
562
  if (goal) sendGoalEvent({ ev: 'task', task: persist }, goal); else send({ ev: 'task', task: persist });
563
+ emitTaskAnalytics(t);
564
+ }
565
+
566
+ // Phase 1 usage analytics (docs/DATA-RETENTION-PLAN.md). Fire-and-forget, no
567
+ // content: a task's creation and its terminal outcome are counted once each,
568
+ // keyed by ANALYTICS_UID (the hashed, non-credential install id). saveTask runs
569
+ // on every status change, so a per-(id,event) Set dedups repeat saves.
570
+ const _analyticsEmitted = new Set();
571
+ const _TERMINAL_EVENT = { done: 'task_completed', failed: 'task_failed', interrupted: 'task_interrupted' };
572
+ function emitTaskAnalytics(t) {
573
+ if (!t || t.id == null) return;
574
+ if (!_analyticsEmitted.has(`created:${t.id}`)) {
575
+ _analyticsEmitted.add(`created:${t.id}`);
576
+ emitEvent('task_created', ANALYTICS_UID, { source: 'engine' });
577
+ }
578
+ const ev = _TERMINAL_EVENT[t.status];
579
+ if (ev && !_analyticsEmitted.has(`${t.status}:${t.id}`)) {
580
+ _analyticsEmitted.add(`${t.status}:${t.id}`);
581
+ emitEvent(ev, ANALYTICS_UID, { status: t.status, source: 'engine' });
582
+ postHistory(t);
583
+ }
584
+ }
585
+
586
+ // Phase 2 cloud history (paid). On a finished task, post an encrypted history
587
+ // record (the Worker gates on the license token and encrypts the content, so
588
+ // free / no-token installs store nothing — their work stays fully local).
589
+ // Fire-and-forget: never blocks or breaks a run.
590
+ function readLicenseToken() {
591
+ try { return existsSync(licenseFile) ? readFileSync(licenseFile, 'utf8').trim() : null; } catch { return null; }
592
+ }
593
+ function postHistory(t) {
594
+ if (!BILLING_API_URL) return;
595
+ const token = readLicenseToken();
596
+ if (!token) return; // no license → nothing stored server-side (stays local)
597
+ const goal = goals.find((g) => g.id === t.goalId);
598
+ const project = projects.find((p) => p.id === t.projectId);
599
+ try {
600
+ fetch(`${BILLING_API_URL}/history`, {
601
+ method: 'POST',
602
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
603
+ body: JSON.stringify({
604
+ id: `t${t.id}`,
605
+ project: project?.name ?? null,
606
+ content: goal?.text ?? null, // the user's request — encrypted server-side
607
+ summary: t.result ?? null, // the task's result — encrypted server-side
608
+ status: t.status,
609
+ createdAt: t.startedAt ? Date.parse(t.startedAt) : Date.now(),
610
+ }),
611
+ }).catch(() => {});
612
+ } catch { /* never throw into the caller */ }
544
613
  }
545
614
  const ACTIVITY_LIMIT = 1000;
546
615
  function activityLine(line) {
@@ -2302,6 +2371,8 @@ const server = createServer(async (req, res) => {
2302
2371
  res.setHeader('set-cookie', `mkey=${ACCESS_KEY}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`);
2303
2372
  }
2304
2373
  }
2374
+ // Once past auth, the first MCP-client request marks setup as completed.
2375
+ maybeEmitSetupCompleted(req);
2305
2376
 
2306
2377
  if (url.pathname === '/' || url.pathname === '/index.html') {
2307
2378
  // no-cache: browsers were keeping a STALE index.html (no cache header before),
@@ -3265,10 +3336,34 @@ const server6 = createServer(server.listeners('request')[0]);
3265
3336
  server6.on('error', (e) => console.log(`[manager] ::1 listen skipped: ${e.code}`));
3266
3337
  try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
3267
3338
 
3339
+ // Guard the primary IPv4 listen the same way the ::1 twin is guarded: without
3340
+ // this, a busy port emits an unhandled 'error' and Node prints a raw stack
3341
+ // trace + crashes. The common cause is a second manager (or `npx @galda/cli`)
3342
+ // started while one is already running on this port — so say that, and how to
3343
+ // pick another port, instead of dumping a stack trace.
3344
+ server.on('error', (e) => {
3345
+ if (e.code === 'EADDRINUSE') {
3346
+ console.error(`[manager] port ${PORT} is already in use — another manager is likely running on it.`);
3347
+ console.error(`[manager] Use a different port: MANAGER_PORT=${PORT + 10} npx @galda/cli`);
3348
+ process.exit(1);
3349
+ }
3350
+ throw e;
3351
+ });
3352
+
3268
3353
  server.listen(PORT, '127.0.0.1', () => {
3269
3354
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3270
3355
  if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3271
- spawn('open', ['-g', `http://localhost:${PORT}/?key=${ACCESS_KEY}`], { stdio: 'ignore' }).unref();
3356
+ // First run in the hosted flow (no license yet, billing worker configured):
3357
+ // open the one-click Google device-link straight away instead of the local
3358
+ // ?key board, so a non-engineer never sees a localhost URL or an access key —
3359
+ // they sign in once and their hosted board comes alive (relay-client rebinds
3360
+ // via its license.token watcher). Otherwise open the local board as before.
3361
+ let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
3362
+ if (BILLING_API_URL && !existsSync(licenseFile)) {
3363
+ openUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
3364
+ console.log('[manager] first run: opening one-click sign-in (no local key needed) → ' + openUrl);
3365
+ }
3366
+ spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
3272
3367
  }
3273
3368
  console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
3274
3369
  syncAllExternal().catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.5",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {