@galda/cli 0.10.19 → 0.10.21

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
@@ -7,10 +7,16 @@
7
7
  - `毎回、実装したものにはテストを書いて、実行する`。
8
8
  - engine/ の変更 → `node --test --test-concurrency=1 'engine/test/*.test.mjs'` が緑になるまで完了と言わない。
9
9
  - 🔴 **`--test-concurrency=1` は必須**(Masa報告のカオス調査で発見・2026-07-16)。既定(CPUコア数)だと
10
- 58ファイルが同時に実サーバを立ててポートを奪い合い、**同じコードで赤が毎回入れ替わる**
11
- (実測: 1回目=2件赤 → 2回目=別の4件赤。`multi-user-server` はサーバが起動せず18msで全滅)。
12
- 直列なら **527 pass / 1 fail / 1 skip** で安定し、所要時間もほぼ同じ(約102秒)。
10
+ 58ファイルが同時に実サーバと Chrome を立てて**マシンを飽和させ、同じコードで赤が毎回入れ替わる**。
13
11
  **「テスト緑」がコードの状態でなく運を報告している状態を放置しない**=規律の土台。
12
+ - **原因の半分は 2026-07-17 に構造で潰した**(PR: test-port-allocation)。各テストが
13
+ `4600 + (pid % 40)` 式で**ポートを手書きし隣接バンドで衝突**していた分は、`MANAGER_PORT=0`+
14
+ `engine/test/helpers/start-server.mjs` に一本化して**原理的に消えた**(OSは同じポートを2つに渡さない)。
15
+ `multi-user-server` の「18msで全滅」はこれ。**テストで新しくポートを書かない**=ヘルパを使う。
16
+ - **残る半分は資源の飽和**(実サーバ+Chrome の総量)。実測 2026-07-17: 並列だと1回目3件赤→2回目2件赤で
17
+ **まだ揺れる上に遅い**(並列152秒 vs 直列135秒)。**並列に戻す根拠は無い**=直列のまま。
18
+ 直列は2回連続で **584 pass / 既知の赤1件** と完全一致。
19
+ 次に上限を上げたければ、テストが実サーバを立てる設計自体(in-process 化等)を変えること。
14
20
  - 既知の赤1件=`persist-failure`(full-disk resilience)。**素の `origin/main` でも落ちる**(`fix/persist-log-disk-full` が未マージ)。
15
21
  自分の変更のせいかは、素の main と比べて判定する。
16
22
  - 新しいロジックは可能な限り純関数として `engine/lib.mjs` 等に切り出し、`engine/test/` にテストを追加する。
@@ -67,6 +73,14 @@
67
73
  - **`a2c-tech.workers.dev` を絶対に使わない・コードやdocsに書かない**。これはクライアント企業 **A2C** の名を冠した Cloudflare workers.dev サブドメインで、Galda の製品・URL・設定に一切登場させてはいけない(見つけたら全て除去)。
68
74
  - Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
69
75
  - **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
76
+ - **リリースは4面**(① npm ② Fly ③ Worker ④ Masaの常駐インスタンス `~/manager-for-ai`:4400)。①だけ出して④を忘れると「直したのに直ってない」+relay相互kickループ再発の実例あり(2026-07-16/17)。全文=`docs/RELEASE-WORKFLOW.md`の「本番は1つじゃない」表。
77
+
78
+ ## relay接続(`app.galda.app`)を伴う作業の掟(Masa明言 2026-07-17・全レーン必読)
79
+
80
+ - **relayは1アカウント=同時1接続のみ**。同じ`MANAGER_HOME`(既定`~/.manager-for-ai`)を指す2つ目のプロセスを起動すると、実本番アカウントの接続を奪い合い、"無限ループ"(app.galda.appのリロード連打)を引き起こす。**6回繰り返した実績のあるインシデントクラス**(`docs/RUNBOOK-relay-mutual-kick.md`参照)。
81
+ - `engine/relay-client.mjs`は2026-07-17からPIDロックで2つ目の起動を自動拒否するが、**手動検証で`node engine/relay-client.mjs`や`npx @galda/cli`を叩く時は、必ず隔離した`MANAGER_HOME`を使う**(本番の`~/.manager-for-ai`を素で使わない)。
82
+ - **"無限ループ"に見えても原因は2系統ある**(プロセスが1個だけの時は相互kickではない=**アカウント不一致**。ローカルのライセンスemailとブラウザのGoogleサインインemailが違うと、プロセスは正常なのに永遠に「未接続」に見える)。`docs/RUNBOOK-relay-mutual-kick.md`の「診断の分岐」でプロセス数を数えてから対応を選ぶ。
83
+ - 「無限ループが起きてる」と報告されたら、コードを読みに行く前に**必ず**`docs/RUNBOOK-relay-mutual-kick.md`の診断手順(`ps aux | grep relay-client`から)に従う。
70
84
 
71
85
  ## 構成
72
86
 
package/app/index.html CHANGED
@@ -6057,8 +6057,16 @@ function bootDisconnected(info){
6057
6057
  #connectGate .cg-hint a:hover{text-decoration:underline}
6058
6058
  #connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
6059
6059
  #connectGate .cg-who b{color:var(--ink2,#a3a097)}
6060
- #connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
6060
+ #connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0;transition:color .2s}
6061
6061
  #connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
6062
+ /* A long wait (15s+) almost always means an account mismatch, not a slow
6063
+ first connect (Masa 2026-07-17: kept re-running npx, kept landing back
6064
+ here — the always-on caption below was too easy to miss). Escalate to
6065
+ the existing warning tone (--amber, same as failed-step/review chips)
6066
+ — color only, no filled box, per the hover/attention convention used
6067
+ everywhere else in this app. */
6068
+ #connectGate .cg-switch.escalated{color:var(--amber);font-weight:600}
6069
+ #connectGate .cg-switch.escalated a{color:var(--amber);text-decoration:underline}
6062
6070
  #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}
6063
6071
  #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}
6064
6072
  #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
@@ -6111,11 +6119,26 @@ function bootDisconnected(info){
6111
6119
  // `setInterval` + `if (status !== 503) reload` poll, which amplified any agent
6112
6120
  // flap into an infinite reload (the ~10-failure "無限ループ").
6113
6121
  const cgWait = ov.querySelector('.cg-wait');
6122
+ const cgSwitch = ov.querySelector('.cg-switch');
6123
+ // Escalate the account-mismatch hint after a long wait (2026-07-17: mirrors
6124
+ // engine/lib.mjs shouldEscalateConnectGateHint — inlined, this file can't
6125
+ // import engine/lib.mjs). A genuine first connect takes a few seconds; a
6126
+ // wait that crosses 15s is almost always the local agent being signed in
6127
+ // under a DIFFERENT email than this browser (relay identity is per-email,
6128
+ // so re-running `npx @galda/cli` — which reconnects that SAME mismatched
6129
+ // identity — never helps; only `--signin` does). Naming the actual signed-
6130
+ // in email turns "I have no idea what's wrong" into "oh, wrong account".
6131
+ const CG_ESCALATE_MS = 15000;
6132
+ const escalateTimer = (email && cgSwitch) ? setTimeout(() => {
6133
+ cgSwitch.classList.add('escalated');
6134
+ cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch this browser →</a>`;
6135
+ }, CG_ESCALATE_MS) : null;
6114
6136
  try {
6115
6137
  const es = new EventSource('/presence');
6116
6138
  es.addEventListener('presence', (e) => {
6117
6139
  let d; try { d = JSON.parse(e.data); } catch { return; }
6118
6140
  if (d.state === 'connected') {
6141
+ if (escalateTimer) clearTimeout(escalateTimer);
6119
6142
  // Confirm the engine is REALLY reachable before reloading. A stale/spurious
6120
6143
  // 'connected' presence otherwise reloads us into a white flash that lands
6121
6144
  // right back on this same connect screen — the welcome animation plays, then
package/engine/lib.mjs CHANGED
@@ -3112,3 +3112,41 @@ export function relayReconnectDecision(state, event) {
3112
3112
  return { state: s, dial: false, reason: 'noop' };
3113
3113
  }
3114
3114
  }
3115
+
3116
+ // --- Relay-client single-instance guard (2026-07-17, five incidents deep) -----
3117
+ // Every relay mutual-kick incident so far (#86/#88/#106, and the one that
3118
+ // prompted this) traces back to the SAME root: nothing has ever stopped more
3119
+ // than one relay-client process from authenticating as the same account at
3120
+ // once — a background launchd instance, a fresh `npx @galda/cli`, and an
3121
+ // engineer's ad-hoc `node engine/relay-client.mjs` test run can all share the
3122
+ // same DATA_DIR (and therefore the same license.token) with zero coordination.
3123
+ // relayReconnectDecision() makes each INDIVIDUAL process behave correctly once
3124
+ // evicted; it can't stop a THIRD, FOURTH, FIFTH process from starting fresh —
3125
+ // a fresh start isn't a "reconnect", so the reducer never even sees it. This
3126
+ // closes that gap at the one point it can be closed structurally: refuse to
3127
+ // start a second live process against the same DATA_DIR at all, the same
3128
+ // pattern tools/lane.mjs uses for hot-file locks (fail at start time, not
3129
+ // after the fact). `alive` is passed in (not computed here) so this stays a
3130
+ // pure decision — the caller does the actual process.kill(pid, 0) liveness
3131
+ // check (real I/O, not testable as a pure function).
3132
+ export function shouldRefuseRelayClientStart({ existingLock, alive } = {}) {
3133
+ if (!existingLock || !Number.isFinite(existingLock.pid)) return { refuse: false, reason: 'no-lock' };
3134
+ if (!alive) return { refuse: false, reason: 'stale-lock' };
3135
+ return { refuse: true, reason: 'already-running', holderPid: existingLock.pid };
3136
+ }
3137
+
3138
+ // --- Connect-gate account-mismatch escalation (2026-07-17) ------------------
3139
+ // A genuinely fresh "run npx @galda/cli" wait is short — the agent connects
3140
+ // within a few seconds. A wait that runs long almost always means one thing:
3141
+ // the local agent IS running, but it's licensed under a DIFFERENT email than
3142
+ // the one signed in on this browser (relay identity is per-email, so they
3143
+ // never meet). Before this, the only guidance for that exact scenario was a
3144
+ // small always-on caption easy to miss among the first-time-setup copy — a
3145
+ // user just re-runs `npx @galda/cli` (which reconnects the SAME already-
3146
+ // mismatched identity) and waits again, forever. Escalating the hint once the
3147
+ // wait crosses a threshold — with the actual signed-in email substituted in —
3148
+ // turns "I have no idea what's wrong" into "oh, wrong account" (see
3149
+ // docs/RUNBOOK-relay-mutual-kick.md's account-mismatch entry).
3150
+ export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 15000) {
3151
+ return Number(waitedMs) >= thresholdMs && Boolean(String(email ?? '').trim());
3152
+ }
@@ -12,7 +12,7 @@ import { readFileSync, writeFileSync, existsSync, watch, unlinkSync } from 'node
12
12
  import { resolve, dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { licenseTokenIdentity, relayReconnectDecision } from './lib.mjs';
15
+ import { licenseTokenIdentity, relayReconnectDecision, shouldRefuseRelayClientStart } from './lib.mjs';
16
16
 
17
17
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
18
  const DATA_DIR = process.env.MANAGER_HOME
@@ -182,7 +182,43 @@ function handleTakeover() {
182
182
  if (d.dial) { console.log('[agent] taking over this account on this device (user requested)'); redialNow(); }
183
183
  }
184
184
 
185
+ // Single-instance guard (2026-07-17): refuse to start a second relay-client
186
+ // against the same DATA_DIR. See lib.mjs shouldRefuseRelayClientStart for why —
187
+ // this is the one point that actually stops the "N processes, one account"
188
+ // mutual-kick class of incident, rather than making each process behave
189
+ // better once it's already one of several. RELAY_CLIENT_FORCE=1 overrides for
190
+ // the rare legitimate case (you know two are up and you're taking over by hand).
191
+ const LOCK_FILE = join(DATA_DIR, 'relay-client.lock');
192
+ function readLock() {
193
+ try { return JSON.parse(readFileSync(LOCK_FILE, 'utf8')); } catch { return null; }
194
+ }
195
+ function isPidAlive(pid) {
196
+ if (!Number.isFinite(pid)) return false;
197
+ try { process.kill(pid, 0); return true; } catch { return false; }
198
+ }
199
+ function writeLock() {
200
+ try { writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); } catch { /* best-effort */ }
201
+ }
202
+ function releaseLock() {
203
+ try {
204
+ const l = readLock();
205
+ if (l && l.pid === process.pid) unlinkSync(LOCK_FILE);
206
+ } catch { /* already gone */ }
207
+ }
208
+
185
209
  if (!process.env.RELAY_CLIENT_NO_START) {
210
+ const existingLock = readLock();
211
+ const guard = shouldRefuseRelayClientStart({ existingLock, alive: existingLock ? isPidAlive(existingLock.pid) : false });
212
+ if (guard.refuse && !process.env.RELAY_CLIENT_FORCE) {
213
+ console.error(`[agent] relay-client: another instance is already running for this account (pid ${guard.holderPid}, ${DATA_DIR}).`);
214
+ console.error('[agent] Running two at once is exactly what causes the relay mutual-kick loop — stop the other one first.');
215
+ console.error('[agent] If you are certain (e.g. taking over by hand), set RELAY_CLIENT_FORCE=1 and re-run.');
216
+ process.exit(1);
217
+ }
218
+ writeLock();
219
+ process.on('exit', releaseLock);
220
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { releaseLock(); process.exit(0); });
221
+
186
222
  connect();
187
223
 
188
224
  // Watch DATA_DIR for two triggers (the file may not exist yet on first run;
package/engine/server.mjs CHANGED
@@ -31,7 +31,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
31
31
  const DATA_DIR = process.env.MANAGER_HOME
32
32
  ?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
33
33
  mkdirSync(DATA_DIR, { recursive: true });
34
- const PORT = Number(process.env.MANAGER_PORT ?? 4400);
34
+ // MANAGER_PORT=0 asks the OS for a free port; the real one is only known once
35
+ // the listen below succeeds, so this is rebound there (before anything prints
36
+ // or links to it).
37
+ let PORT = Number(process.env.MANAGER_PORT ?? 4400);
35
38
 
36
39
  // Access key: required for every request when the server is exposed beyond
37
40
  // localhost (e.g. through a tunnel). Auto-generated once, kept out of git.
@@ -3958,7 +3961,6 @@ const server = createServer(async (req, res) => {
3958
3961
  // Same request handler, loopback-only, best-effort (skip if ::1 unavailable).
3959
3962
  const server6 = createServer(server.listeners('request')[0]);
3960
3963
  server6.on('error', (e) => console.log(`[manager] ::1 listen skipped: ${e.code}`));
3961
- try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
3962
3964
 
3963
3965
  // Guard the primary IPv4 listen the same way the ::1 twin is guarded: without
3964
3966
  // this, a busy port emits an unhandled 'error' and Node prints a raw stack
@@ -3975,6 +3977,8 @@ server.on('error', (e) => {
3975
3977
  });
3976
3978
 
3977
3979
  server.listen(PORT, '127.0.0.1', () => {
3980
+ PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
3981
+ try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
3978
3982
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3979
3983
  if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3980
3984
  // Hosted flow (billing worker + named app configured): the user lives in
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {
7
7
  "start": "node engine/server.mjs",
8
- "test": "node --test --test-concurrency=2 'engine/test/*.test.mjs'"
8
+ "test": "node --test --test-concurrency=1 'engine/test/*.test.mjs'"
9
9
  },
10
10
  "dependencies": {
11
11
  "puppeteer-core": "^23.11.1",