@galda/cli 0.10.2 → 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
@@ -5752,7 +5752,11 @@ function bootDisconnected(info){
5752
5752
  const gate = (e) => { if (e) e.preventDefault(); open(); };
5753
5753
  if (input) { input.readOnly = true; input.addEventListener('focus', gate); input.addEventListener('mousedown', gate); input.addEventListener('keydown', gate); }
5754
5754
  if (send) send.addEventListener('click', gate);
5755
- const cw = document.getElementById('composerWrap'); if (cw) cw.addEventListener('mousedown', 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); });
5756
5760
 
5757
5761
  // come alive automatically: when the relay stops flagging disconnected (the
5758
5762
  // engine connected → its live app is proxied), reload into the real board.
@@ -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
 
@@ -9,7 +9,9 @@
9
9
  // are swallowed so analytics can never break a task run or block the caller.
10
10
  //
11
11
  // Hook points (wiring lives in engine/relay, kept out of this module):
12
- // signup → relay Google callback, right after the session is minted
12
+ // signup → engine, once per install when the MCP client (Claude
13
+ // Code / Codex) first connects = "setup completed"
14
+ // (maybeEmitSetupCompleted in server.mjs)
13
15
  // task_created → engine, when a task/goal is accepted
14
16
  // task_completed → engine, task.status = 'done'
15
17
  // task_failed → engine, task.status = 'failed'
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
@@ -20,7 +20,7 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
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 } 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';
24
24
  import { openPR } from './pr.mjs';
25
25
  import { runVerification, exerciseUi } from './verify.mjs';
26
26
 
@@ -41,6 +41,20 @@ const ACCESS_KEY = readFileSync(keyFile, 'utf8').trim();
41
41
  // access key so the raw credential NEVER leaves the machine (the billing Worker
42
42
  // hashes this again into its pseudonymous id). Not the access key itself.
43
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
+ }
44
58
  // Per-worker run cap. Env-configurable so it can be tuned operationally and the
45
59
  // timeout→'interrupted' path is testable (a test sets a short value + a slow
46
60
  // fake worker instead of waiting 10 real minutes).
@@ -2357,6 +2371,8 @@ const server = createServer(async (req, res) => {
2357
2371
  res.setHeader('set-cookie', `mkey=${ACCESS_KEY}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`);
2358
2372
  }
2359
2373
  }
2374
+ // Once past auth, the first MCP-client request marks setup as completed.
2375
+ maybeEmitSetupCompleted(req);
2360
2376
 
2361
2377
  if (url.pathname === '/' || url.pathname === '/index.html') {
2362
2378
  // no-cache: browsers were keeping a STALE index.html (no cache header before),
@@ -3337,7 +3353,17 @@ server.on('error', (e) => {
3337
3353
  server.listen(PORT, '127.0.0.1', () => {
3338
3354
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3339
3355
  if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3340
- 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();
3341
3367
  }
3342
3368
  console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
3343
3369
  syncAllExternal().catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.2",
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": {