@galda/cli 0.10.17 → 0.10.19

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.
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { join, resolve, dirname } from 'node:path';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
  import { createServer } from 'node:net';
14
+ import { isCommandOnPath, resolveConnectedAgents } from '../engine/lib.mjs';
14
15
 
15
16
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const say = (m) => console.log(`[galda] ${m}`);
@@ -36,21 +37,26 @@ async function pickPort(start) {
36
37
  const WIN = process.platform === 'win32';
37
38
  const probe = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', shell: WIN });
38
39
 
39
- // 1) claude CLI (the enginerequired)
40
+ // 1) Worker agent CLIs. Galda drives whichever you have Claude Code OR Codex;
41
+ // at least one is required. Claude Code is safe to run for detection (notarized),
42
+ // so we probe it for a version string. Codex is detected by a PATH lookup ONLY —
43
+ // never executed — because running the codex binary can trip macOS XProtect (a
44
+ // false positive that looks like Galda shipped malware). Galda never installs or
45
+ // downloads either; it only offers what's already on the machine.
40
46
  const claude = probe('claude', ['--version']);
41
- if (claude.error || claude.status !== 0) {
42
- say('ERROR: the `claude` CLI was not found.');
43
- say('Galda runs on your Claude Code subscription (no extra API cost).');
44
- say('Install it first: https://claude.com/claude-codethen log in with `claude`.');
47
+ const hasClaude = !claude.error && claude.status === 0;
48
+ const hasCodex = isCommandOnPath('codex', { pathEnv: process.env.PATH || '', sep: WIN ? ';' : ':', pathExt: WIN ? (process.env.PATHEXT || '') : '' });
49
+ if (!hasClaude && !hasCodex) {
50
+ say('ERROR: no agent CLI found Galda needs Claude Code or Codex on this machine.');
51
+ say('Install Claude Code (https://claude.com/claude-code — runs on your subscription, no extra API cost)');
52
+ say('or Codex, then run `npx @galda/cli` again.');
45
53
  process.exit(1);
46
54
  }
47
- say(`claude CLI: ${claude.stdout.trim()}`);
48
-
49
- // 1b) codex CLI (optional alternate worker). Informational only so Codex users
50
- // see it's detected and supported, not just Claude Code. Galda can run workers on
51
- // either; you pick per task (Claude Code / Codex) in the board's composer.
52
- const codex = probe('codex', ['--version']);
53
- if (!codex.error && codex.status === 0) say(`codex CLI: ${codex.stdout.trim()} (Codex)`);
55
+ if (hasClaude) say(`claude CLI: ${claude.stdout.trim()}`);
56
+ if (hasCodex) say('codex CLI: detected (Codex)');
57
+ // Hand the detected set to the server so the board only offers installed agents.
58
+ const availableAgents = resolveConnectedAgents({ claude: hasClaude, codex: hasCodex });
59
+ process.env.MANAGER_AVAILABLE_AGENTS = availableAgents.join(',');
54
60
 
55
61
  // 2) Chrome (for verification proof) — recommended; warn if missing
56
62
  const chromeCandidates = [
package/engine/lib.mjs CHANGED
@@ -5,6 +5,62 @@ import { pathToFileURL } from 'node:url';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
6
  import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
7
7
 
8
+ // Worker agents Galda can drive. Galda is NOT tied to Claude Code — it runs on
9
+ // whichever agent CLI you have. Neither is auto-installed and codex is never even
10
+ // executed to detect it (running the codex binary can trip macOS XProtect — a
11
+ // false-positive that looks like Galda shipped malware); its presence is detected
12
+ // by a plain PATH lookup (isCommandOnPath). An agent is offered iff its CLI is
13
+ // installed, so a Codex-only machine (no claude) still gets a working board.
14
+ export const SUPPORTED_WORKER_AGENTS = ['claude-code', 'codex'];
15
+
16
+ // Canonical order, filtered to what's actually installed on this machine.
17
+ export function resolveConnectedAgents({ claude = false, codex = false } = {}) {
18
+ const agents = [];
19
+ if (claude) agents.push('claude-code');
20
+ if (codex) agents.push('codex');
21
+ return agents;
22
+ }
23
+
24
+ // Clamp a requested agent to one that's actually available — so a stale UI
25
+ // default (e.g. 'claude-code' on a Codex-only machine) still runs on the agent
26
+ // the user has, instead of spawning a `claude` that isn't there.
27
+ export function pickAvailableAgent(requested, available = []) {
28
+ const list = Array.isArray(available) ? available.filter(Boolean) : [];
29
+ if (requested && list.includes(requested)) return requested;
30
+ return list[0] ?? 'claude-code';
31
+ }
32
+
33
+ // Is `cmd` an executable on PATH? Detected WITHOUT running it (pure fs lookup) —
34
+ // this is how codex is detected so Galda never executes the binary. On Windows a
35
+ // bare `codex` resolves via PATHEXT (.cmd/.exe/…); pass pathExt + sep=';' there.
36
+ export function isCommandOnPath(cmd, { pathEnv = '', sep = ':', pathExt = '', existsFn } = {}) {
37
+ if (!cmd) return false;
38
+ const exists = existsFn || existsSync;
39
+ const dirs = String(pathEnv).split(sep).filter(Boolean);
40
+ const exts = ['', ...String(pathExt).split(';').map((e) => e.trim()).filter(Boolean)];
41
+ for (const dir of dirs) {
42
+ for (const ext of exts) {
43
+ try { if (exists(join(dir, cmd + ext))) return true; } catch { /* unreadable dir — skip */ }
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // Galda makes small internal LLM calls (composer-intent classification, proof-card
50
+ // summaries, board Q&A). These prefer Claude Code + haiku (cheap + fast), but must
51
+ // still work on a Codex-only machine — so they run on whatever agent is available,
52
+ // NOT a hardcoded `claude` that isn't installed. Pure so the routing is testable.
53
+ export function pickUtilityAgent(available = []) {
54
+ const list = Array.isArray(available) ? available.filter(Boolean) : [];
55
+ if (list.includes('claude-code')) return 'claude-code';
56
+ return list[0] ?? 'claude-code';
57
+ }
58
+ // The cheapest/fastest model for each agent's small utility calls. (runCodex
59
+ // re-validates against the real Codex model list, so an unknown value is safe.)
60
+ export function utilityModel(agent) {
61
+ return agent === 'codex' ? 'gpt-5.4-mini' : 'haiku';
62
+ }
63
+
8
64
  // Build the URL a headless-browser verifier should open for a goal's entry
9
65
  // point. Plain http(s) entries pass through unchanged. A relative path is
10
66
  // normally opened as a bare file:// page — but the Manager's own UI
@@ -206,23 +262,71 @@ export function buildContextHandoffSummary({ goal = null, tasks = [] } = {}) {
206
262
  ].filter(Boolean).join('\n').slice(0, 4000);
207
263
  }
208
264
 
209
- export function estimateTokenOptimization({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null } = {}) {
265
+ // task 6 (2026-07-16): "saved X%" used to be a proxy for cache-hit ratio
266
+ // this goal's own cache-read tokens discounted 0.1x, compared against this
267
+ // same goal's raw total. That's real math, but it has nothing to do with
268
+ // "cheaper than usual": a goal with heavy caching always scores high even if
269
+ // it was worse than the norm, and a goal with no caching always scores 0
270
+ // even if it finished in a third of the tokens via a tight plan. Below,
271
+ // `tokenBaselinePerRequirement` turns a list of past finished goals into a
272
+ // moving average of tokens-per-requirement, so `estimateTokenOptimization`
273
+ // can compare THIS goal against that real baseline when enough history
274
+ // exists (`basis: 'baseline'`) — and only falls back to the old cache-ratio
275
+ // proxy (`basis: 'cache-weight'`) when there isn't enough history yet.
276
+ const TOKEN_BASELINE_MIN_HISTORY = 3;
277
+ const TOKEN_BASELINE_MAX_HISTORY = 20;
278
+
279
+ export function tokenBaselinePerRequirement(history = []) {
280
+ const samples = (history ?? [])
281
+ .map((h) => {
282
+ if (typeof h === 'number') return h;
283
+ const tokens = Number(h?.weightedTokens);
284
+ const reqN = Math.max(1, Number(h?.requirementCount) || 1);
285
+ return Number.isFinite(tokens) ? tokens / reqN : null;
286
+ })
287
+ .filter((n) => Number.isFinite(n) && n > 0)
288
+ .slice(-TOKEN_BASELINE_MAX_HISTORY);
289
+ if (samples.length < TOKEN_BASELINE_MIN_HISTORY) return null;
290
+ return samples.reduce((a, b) => a + b, 0) / samples.length;
291
+ }
292
+
293
+ export function estimateTokenOptimization({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}) {
210
294
  const u = usage ?? {};
211
295
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
296
+ if (raw <= 0) return null;
212
297
  const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens))
213
298
  ? Number(weightedTokens)
214
299
  : usageBudgetTokens(u, { cacheReadWeight });
215
- const saved = Math.max(0, raw - weighted);
216
- const percent = raw > 0 ? Math.round((saved / raw) * 100) : 0;
300
+ const reqN = Math.max(1, Number(requirementCount) || 1);
301
+ const perReq = weighted / reqN;
302
+ const baseline = tokenBaselinePerRequirement(history);
303
+
304
+ let percent, basis, savedTokens;
305
+ if (baseline != null) {
306
+ percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
307
+ basis = 'baseline';
308
+ savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
309
+ } else {
310
+ savedTokens = Math.max(0, raw - weighted);
311
+ percent = Math.round((savedTokens / raw) * 100);
312
+ basis = 'cache-weight';
313
+ }
314
+
315
+ // "why": always at least one concrete, data-derived sentence — never a
316
+ // generic "session planning" filler with nothing behind it.
217
317
  const techniques = [];
218
- if ((u.cache_read_input_tokens ?? 0) > 0) techniques.push('cache-read weighting');
219
- if (['compact', 'cold-handoff'].includes(sessionMode)) techniques.push(sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff');
220
- if (usedHandoff) techniques.push('AI handoff');
221
- if (usedCompact) techniques.push('/compact');
222
- if (usedClear) techniques.push('/clear');
223
- if (agentModel) techniques.push(`model ${agentModel}`);
224
- if (!percent && !techniques.length) return null;
225
- return { rawTokens: raw, weightedTokens: weighted, savedTokens: saved, percent, techniques: [...new Set(techniques)] };
318
+ const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
319
+ if (cacheRatio > 0) techniques.push(`${cacheRatio}% of tokens served from cache`);
320
+ if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
321
+ if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
322
+ if (usedClear) techniques.push('/clear between unrelated goals');
323
+ if (agentModel) techniques.push(`${agentModel} for this run`);
324
+ if (basis === 'baseline' && percent > 0) {
325
+ techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
326
+ }
327
+ if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
328
+
329
+ return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
226
330
  }
227
331
 
228
332
  export function estimateFailureCauseMix({ usage = null, blockedKind = null, changedFiles = [], testResult = null, attentionHadDetail = true, taskCount = 1 } = {}) {
@@ -1666,6 +1770,94 @@ export function reconcileOrphanGoals(goals, tasks) {
1666
1770
  return out;
1667
1771
  }
1668
1772
 
1773
+ // ---- cross-device board sync (P3, docs/design/CROSS-DEVICE-SYNC-PLAN.md) ----
1774
+ // The "board" is the portable snapshot of one install's local-first state that
1775
+ // the sync Worker (P2, PUT/GET /sync/board) stores per account, so a second
1776
+ // device signed into the same account inherits the work on boot. These are the
1777
+ // pure, unit-tested decision helpers; engine/server.mjs owns the actual disk +
1778
+ // network I/O and calls into these.
1779
+ export const BOARD_SNAPSHOT_VERSION = 1;
1780
+
1781
+ // Assemble the snapshot server.mjs pushes. tasksLog is the raw text of
1782
+ // chat-runs/tasks.jsonl (the append-only heart of the board — goals + tasks);
1783
+ // the other three are the small JSON state files. Defensive about missing
1784
+ // pieces so a partial local state still produces a valid, replayable board.
1785
+ export function buildBoardSnapshot({ projects, tasksLog, workflowColumns, reviewDefinitions } = {}) {
1786
+ return {
1787
+ v: BOARD_SNAPSHOT_VERSION,
1788
+ projects: Array.isArray(projects) ? projects : [],
1789
+ tasksLog: typeof tasksLog === 'string' ? tasksLog : '',
1790
+ workflowColumns: workflowColumns && typeof workflowColumns === 'object' ? workflowColumns : {},
1791
+ reviewDefinitions: reviewDefinitions && typeof reviewDefinitions === 'object' ? reviewDefinitions : {},
1792
+ };
1793
+ }
1794
+
1795
+ // Validate/normalize a board pulled from the store before we let it overwrite
1796
+ // local state. Returns the normalized pieces, or null if it's malformed or a
1797
+ // version we don't understand (caller must then leave local state untouched).
1798
+ export function validateBoardSnapshot(board) {
1799
+ if (!board || typeof board !== 'object') return null;
1800
+ if (board.v !== BOARD_SNAPSHOT_VERSION) return null;
1801
+ if (!Array.isArray(board.projects)) return null;
1802
+ if (typeof board.tasksLog !== 'string') return null;
1803
+ return {
1804
+ projects: board.projects,
1805
+ tasksLog: board.tasksLog,
1806
+ workflowColumns: board.workflowColumns && typeof board.workflowColumns === 'object' ? board.workflowColumns : {},
1807
+ reviewDefinitions: board.reviewDefinitions && typeof board.reviewDefinitions === 'object' ? board.reviewDefinitions : {},
1808
+ };
1809
+ }
1810
+
1811
+ // A board carries real work only if its task log has content — projects.json
1812
+ // always has at least the 'default' project, so it can't be the emptiness
1813
+ // signal. Used to decide whether a pull would discard un-synced local work.
1814
+ export function boardIsEmpty(snapshot) {
1815
+ if (!snapshot) return true;
1816
+ return !snapshot.tasksLog || !snapshot.tasksLog.trim();
1817
+ }
1818
+
1819
+ // Monotonic optimistic-lock rev: pull only when the store is strictly ahead of
1820
+ // what this device last saw. Equal/older → nothing to adopt (we're current or
1821
+ // we're the writer). Missing/garbage revs coerce to 0.
1822
+ export function shouldPull({ localRev, remoteRev }) {
1823
+ const l = Number(localRev) || 0;
1824
+ const r = Number(remoteRev) || 0;
1825
+ return r > l;
1826
+ }
1827
+
1828
+ // Boot-time decision: should we hydrate from the remote board, and does doing
1829
+ // so throw away un-synced local work? clobbersLocal is the danger case — a
1830
+ // device that has never synced (localRev 0) but already has local goals, while
1831
+ // another device has pushed a board. Cross-device is takeover, not concurrent
1832
+ // offline editing (a non-goal per the plan): last-writer wins, but the caller
1833
+ // logs a warning when clobbersLocal so it's never silent.
1834
+ export function decideBoardPull({ localRev, remoteRev, localHasWork, remoteHasWork }) {
1835
+ const pull = shouldPull({ localRev, remoteRev });
1836
+ const clobbersLocal = pull && Boolean(localHasWork) && Boolean(remoteHasWork) && (Number(localRev) || 0) === 0;
1837
+ return { pull, clobbersLocal };
1838
+ }
1839
+
1840
+ // Live (mid-session) takeover decision (P4). A boot pull runs before any queue
1841
+ // or worker exists, so it can always hydrate; a *live* pull runs on an
1842
+ // already-serving instance, where hydrateFromBoard rewrites the append-only log
1843
+ // wholesale and reassigns goals/tasks. That is only safe when this device has
1844
+ // no in-flight work — a running/planning goal owns live worker children whose
1845
+ // completion would then write against goals that no longer exist. So this gate
1846
+ // refuses when `busy`, and otherwise reuses decideBoardPull for the "is the
1847
+ // store actually newer?" question. Returns a single `action`:
1848
+ // 'disabled' — sync off (no billing/token, or store un-provisioned)
1849
+ // 'busy' — this device has active work; refuse (caller returns 409)
1850
+ // 'invalid' — remote board missing/unparseable; nothing safe to adopt
1851
+ // 'current' — remote is not ahead; already up to date
1852
+ // 'pull' — adopt the remote board (clobbersLocal flags a last-writer wipe)
1853
+ export function liveTakeoverDecision({ syncEnabled, busy, remoteValid, localRev, remoteRev, localHasWork, remoteHasWork }) {
1854
+ if (!syncEnabled) return { action: 'disabled' };
1855
+ if (busy) return { action: 'busy' };
1856
+ if (!remoteValid) return { action: 'invalid' };
1857
+ const { pull, clobbersLocal } = decideBoardPull({ localRev, remoteRev, localHasWork, remoteHasWork });
1858
+ return pull ? { action: 'pull', clobbersLocal } : { action: 'current' };
1859
+ }
1860
+
1669
1861
  // The four states the engine actually tracks per goal/task (see
1670
1862
  // goalGroupStatus/goalChip above). A project's workflow columns are a
1671
1863
  // display-layer relabeling/reordering on top of these — every column must
@@ -1923,7 +2115,7 @@ export function shouldShowGoalSummary(goal) {
1923
2115
  // Mirrored in app/index.html (buildReviewSummary) since the inline browser
1924
2116
  // script can't import this module. No LLM — composed from goal.plan / task
1925
2117
  // titles / task状態.
1926
- export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
2118
+ export function buildReviewSummary({ goal, tasks, approvedN = 0, tokenHistory = [] } = {}) {
1927
2119
  const reqs = (tasks ?? []).filter((t) => !t.reply);
1928
2120
  const total = reqs.length;
1929
2121
  const done = reqs.filter((t) => ['done', 'skipped'].includes(t.status)).length;
@@ -1952,10 +2144,26 @@ export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
1952
2144
  usedCompact: goal?.executionPlan?.sessionMode === 'compact',
1953
2145
  usedClear: Boolean(goal?.sessionClosedAt),
1954
2146
  agentModel: goal?.model,
2147
+ requirementCount: total,
2148
+ history: tokenHistory,
1955
2149
  }),
1956
2150
  };
1957
2151
  }
1958
2152
 
2153
+ // task 6 (2026-07-16): turns a project's finished goals into the `history`
2154
+ // list `estimateTokenOptimization` needs for its baseline comparison — one
2155
+ // {weightedTokens, requirementCount} sample per finished goal, oldest-first,
2156
+ // excluding the goal currently being summarized. Mirrored in app/index.html
2157
+ // (goalTokenHistory) since the inline script can't import this module.
2158
+ export function goalTokenHistory({ goals = [], tasks = [], excludeGoalId = null } = {}) {
2159
+ return (goals ?? [])
2160
+ .filter((g) => g && g.id !== excludeGoalId && GOAL_COMPLETE_STATUSES.includes(g.status) && g.runTokens != null)
2161
+ .map((g) => ({
2162
+ weightedTokens: g.runTokens,
2163
+ requirementCount: (tasks ?? []).filter((t) => t.goalId === g.id && !t.reply).length,
2164
+ }));
2165
+ }
2166
+
1959
2167
  // Task 114: the persistent bottom-right Log box (formerly the running-task-only
1960
2168
  // #actpanel) defaults to collapsed (1 line) and expands on click. Plain boolean
1961
2169
  // flip, but pulled out as a pure function so the click handler has a unit test
@@ -2641,6 +2849,188 @@ export function isFolderInitConfirmed(answer, question = {}) {
2641
2849
  return /^(yes|y|はい|ok|する|始める)$/i.test(a);
2642
2850
  }
2643
2851
 
2852
+ // Onboarding funnel (task 5, PR運用ヒアリング): before a project's very first
2853
+ // runnable goal actually starts work, ask once whether this project wants
2854
+ // PRs (Masa: PR使う人/使わない人で欲しい挙動が違うのに今はサイレントに
2855
+ // defaultWantsPR:false が適用され、設定パネルを開かないと気づけない). Reuses
2856
+ // the same needsInput clarifying-card mechanism as the folder question
2857
+ // (kind:'reviewPref' instead of 'folder') so no new UI surface is needed.
2858
+ // `askedBefore` is a per-project flag (project.reviewPrefAsked) set the
2859
+ // moment the question is asked — so a project is asked at most once ever,
2860
+ // even if the user ignores the card and later goals get created for the
2861
+ // same project while it's still pending.
2862
+ export function needsReviewPreference({ askedBefore } = {}) {
2863
+ return !askedBefore;
2864
+ }
2865
+
2866
+ // Builds the "PR使いますか?" clarifying question. options[0] = wants PR,
2867
+ // options[1] = no PR (direct). Order matters — resolveReviewPreferenceAnswer
2868
+ // below keys off these exact indices/labels.
2869
+ export function buildReviewPreferenceQuestion({ lang = 'ja' } = {}) {
2870
+ const text = lang === 'en'
2871
+ ? 'Before the first task: do you want changes shipped as a PR to review, or applied directly?'
2872
+ : '最初のタスクの前に:実装はPRでレビューしますか、それとも直接反映しますか?';
2873
+ const options = lang === 'en'
2874
+ ? ['PR — open a pull request to review', 'No PR — apply directly (still lands in Review to check)']
2875
+ : ['PRを作ってレビューする', 'PRなしで直接反映する(Reviewでの確認はある)'];
2876
+ return { text, options, kind: 'reviewPref', lang };
2877
+ }
2878
+
2879
+ // Resolve a review-preference answer into a partial review-definition patch
2880
+ // (merged into the project's stored definition by the caller). Matches by
2881
+ // exact option label; an unrecognized/typed answer defaults to wanting a PR
2882
+ // — the original pre-task-5 default — rather than silently turning PRs off.
2883
+ export function resolveReviewPreferenceAnswer(answer, question) {
2884
+ const noPrOption = question?.options?.[1];
2885
+ const wantsPR = String(answer ?? '').trim() !== String(noPrOption ?? '');
2886
+ return { defaultWantsPR: wantsPR };
2887
+ }
2888
+
2889
+ // --- Intent router: no free-text field is a dead-end ----------------------
2890
+ // Every needsInput card has a free-text box. A reply typed there may be the
2891
+ // answer the card expects — OR a meta-instruction ("日本語で教えて", "別のを提案して").
2892
+ // The old code coerced anything unrecognized into a literal (`dir = resolved
2893
+ // ?.dir || answer`), so a Japanese instruction became a folder NAMED "日本語で
2894
+ // 教えて". The router below decides, WITHOUT coercion, which of three paths a
2895
+ // reply takes; the server fulfils each (rebuild card / relocalize / ask model).
2896
+ // All pure + tested; the server supplies filesystem/LLM I/O.
2897
+
2898
+ // Does this string look like a filesystem path (vs prose)? Mirrors the accept
2899
+ // rule resolveFolderAnswer already used (absolute / home-relative), plus
2900
+ // explicit-relative and any embedded slash. Prose like "日本語で教えて" has no
2901
+ // slash → not a path → never silently turned into a folder.
2902
+ export function looksLikePath(s) {
2903
+ const a = String(s ?? '').trim();
2904
+ if (!a) return false;
2905
+ return a.startsWith('/') || a.startsWith('~') || a.startsWith('./') || a.startsWith('../') || a.includes('/');
2906
+ }
2907
+
2908
+ // Detect a reply whose PRIMARY intent is to switch the conversation language,
2909
+ // e.g. "日本語で教えて" / "in English please". Returns 'ja' | 'en' | null.
2910
+ // Deliberately narrow: only fires on short, language-focused messages, so a
2911
+ // real task that merely mentions "英語のドキュメント" isn't misread as a switch.
2912
+ // The cards render in ja/en; any other target language returns null here and is
2913
+ // left for the model (which can restate in any language).
2914
+ export function detectLanguageRequest(text) {
2915
+ const a = String(text ?? '').trim();
2916
+ if (!a || a.length > 40) return null;
2917
+ const lo = a.toLowerCase();
2918
+ const wantsJa = /(日本語|にほんご|ニホンゴ|japanese|nihongo)/.test(lo);
2919
+ const wantsEn = /(英語|えいご|エイゴ|english|eigo)/.test(lo);
2920
+ if (wantsJa && !wantsEn) return 'ja';
2921
+ if (wantsEn && !wantsJa) return 'en';
2922
+ return null;
2923
+ }
2924
+
2925
+ // Decide how a free-text answer to a needsInput card should be handled, WITHOUT
2926
+ // coercing prose into a literal. `question` is goal.question ({kind, options,
2927
+ // lang, folders, yes, no, …}). Returns one of:
2928
+ // { route: 'accept' } — a clean answer; caller runs the card's resolver
2929
+ // { route: 'relocalize', lang } — a language switch; caller re-renders the SAME
2930
+ // question in `lang` (never loses the user's place)
2931
+ // { route: 'escalate' } — free-form intent that isn't a clean answer;
2932
+ // caller hands it to the model / re-asks safely
2933
+ export function routeQuestionAnswer(answer, question = {}) {
2934
+ const a = String(answer ?? '').trim();
2935
+ if (!a) return { route: 'escalate' };
2936
+
2937
+ // Picking an offered option verbatim is always a clean answer (any kind).
2938
+ const options = Array.isArray(question.options) ? question.options : [];
2939
+ if (options.some((o) => o === a)) return { route: 'accept' };
2940
+
2941
+ // A language-switch request → relocalize. Suppress only when the card is
2942
+ // KNOWN to already be in that language (question.lang set and equal); a card
2943
+ // without a declared lang (general planner clarify) always relocalizes since
2944
+ // we can't prove it's already there — the model restating is harmless.
2945
+ const lang = detectLanguageRequest(a);
2946
+ if (lang && lang !== question.lang) return { route: 'relocalize', lang };
2947
+
2948
+ switch (question.kind) {
2949
+ case 'folder': {
2950
+ // A picked label, an exact known dir, or a path-like reply is a real answer.
2951
+ // Bare prose is NOT — escalate instead of naming a folder after it.
2952
+ if ((question.folders || []).some((f) => f.label === a || f.dir === a)) return { route: 'accept' };
2953
+ return looksLikePath(a) ? { route: 'accept' } : { route: 'escalate' };
2954
+ }
2955
+ case 'folder-init': {
2956
+ // Yes / an explicit decline are both clean answers; ambiguous prose escalates.
2957
+ if (isFolderInitConfirmed(a, question)) return { route: 'accept' };
2958
+ if (question.no && a === question.no) return { route: 'accept' };
2959
+ if (/^(no|n|いいえ|やめる|キャンセル|cancel)$/i.test(a)) return { route: 'accept' };
2960
+ return { route: 'escalate' };
2961
+ }
2962
+ default:
2963
+ // General planner clarify card: any prose is a legitimate clarification —
2964
+ // the existing path folds it into goal.text and re-plans (the model sees
2965
+ // it). Keep that; only language switches diverted above.
2966
+ return { route: 'accept' };
2967
+ }
2968
+ }
2969
+
2970
+ // A short localized note prepended to a re-asked card when the reply couldn't be
2971
+ // understood as an answer — so the user learns what the box expects instead of
2972
+ // silently looping. Folded into the card's existing `text` (no new UI field).
2973
+ export function notUnderstoodNote(lang = 'ja') {
2974
+ return lang === 'en'
2975
+ ? 'I couldn’t read that as a folder — type a path (e.g. /Users/you/proj) or pick one above. '
2976
+ : 'それはフォルダとして解釈できませんでした。パス(例: /Users/you/proj)を入力するか、上から選んでください。';
2977
+ }
2978
+
2979
+ // Parse the model's answer when we ask it to restate a pending clarify question
2980
+ // in another language (general cards have no builder to rebuild from). Expects
2981
+ // JSON {"text": "...", "options": ["..."]}. Returns {text, options} shaped like
2982
+ // a clarify card, or null when the model didn't return usable JSON (caller keeps
2983
+ // the original card). Options are capped to the same bounds as planGoal.
2984
+ export function parseRelocalizedQuestion(raw) {
2985
+ try {
2986
+ const m = String(raw ?? '').match(/\{[\s\S]*\}/);
2987
+ if (!m) return null;
2988
+ const o = JSON.parse(m[0]);
2989
+ const text = String(o?.text ?? o?.question ?? '').trim().slice(0, 300);
2990
+ if (!text) return null;
2991
+ const options = Array.isArray(o?.options) ? o.options.slice(0, 4).map((x) => String(x).slice(0, 60)) : [];
2992
+ return { text, options };
2993
+ } catch {
2994
+ return null;
2995
+ }
2996
+ }
2997
+
2998
+ // --- Worker auth preflight -------------------------------------------------
2999
+ // The manager runs every worker by spawning a standalone `claude` (server.mjs
3000
+ // runClaude), which inherits the LAUNCHING SHELL's login. If that shell has no
3001
+ // usable `claude` credentials, every task fails late with "Not logged in ·
3002
+ // Please run /login" (root cause of Masa's all-tasks-failed report). A boot
3003
+ // preflight probes once so the server can raise a banner and hold the queue
3004
+ // instead of failing task after task. Pure so we can test the classification
3005
+ // without spawning a process. NOTE: `claude -p` exits 0 even when logged out,
3006
+ // so we classify on the TEXT, not the exit code.
3007
+ export function classifyAuthProbe({ result = '', code = 0 } = {}) {
3008
+ const text = String(result ?? '');
3009
+ if (/not logged in|please run\s*\/login|\brun \/login\b/i.test(text)) return { ok: false, reason: 'logged-out' };
3010
+ if (/invalid api key|authentication_error|\bunauthorized\b|not authenticated|401\b/i.test(text)) return { ok: false, reason: 'bad-credentials' };
3011
+ if (code !== 0) return { ok: false, reason: 'probe-failed' };
3012
+ return { ok: true, reason: null };
3013
+ }
3014
+
3015
+ // One-line, actionable banner text for an auth-preflight failure. Tells the user
3016
+ // exactly how to fix it: sign the standalone CLI in, then RELAUNCH the manager
3017
+ // from that shell (the worker inherits that shell's login). Localized; the app
3018
+ // chrome is English (DESIGN-RULES) so the client mirrors the 'en' strings.
3019
+ export function authBannerText(reason, lang = 'ja') {
3020
+ const en = {
3021
+ 'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
3022
+ 'probe-failed': 'Couldn’t verify `claude` sign-in. Make sure the `claude` CLI is installed and signed in, then relaunch the manager.',
3023
+ 'logged-out': 'Not signed in to `claude`. Run `claude` in a terminal and /login, then relaunch the manager from that same terminal. Tasks are paused until then.',
3024
+ };
3025
+ const ja = {
3026
+ 'bad-credentials': 'worker のサインインに失敗しました。ANTHROPIC_API_KEY を確認するか、ターミナルで `claude` を起動して /login し、そのターミナルから manager を再起動してください。',
3027
+ 'probe-failed': '`claude` のサインインを確認できませんでした。`claude` CLI がインストール済みでログイン済みか確認し、manager を再起動してください。',
3028
+ 'logged-out': 'ターミナルで `claude` にログインしてから起動してください(`claude` → /login 後、同じターミナルで manager を再起動)。ログインするまでタスクは実行しません。',
3029
+ };
3030
+ const table = lang === 'en' ? en : ja;
3031
+ return table[reason] || table['logged-out'];
3032
+ }
3033
+
2644
3034
  // Analytics identity (A+): a signed-in install scopes usage events to the
2645
3035
  // ACCOUNT (its email, which the billing Worker hashes into the same account_id
2646
3036
  // the accounts table carries) so per-user app activity joins WITHOUT a cookie;
@@ -2654,3 +3044,71 @@ export function pickAnalyticsUid(accountEmail, installId) {
2654
3044
  export function shouldEmitFreeExhausted(alreadyEmitted, blocked) {
2655
3045
  return !alreadyEmitted && Boolean(blocked);
2656
3046
  }
3047
+
3048
+ // --- relay stand-down: kill the two-device mutual-kick loop --------------------
3049
+ // The relay binds ONE agent socket per account (idForEmail(email)); a newer
3050
+ // connection evicts the older one with close code 4000. License tokens are
3051
+ // SHORT-LIVED (7d) and the app re-fetches a fresh one while online, so the SAME
3052
+ // email's license.token string CHANGES over time. Without care, that re-mint is
3053
+ // indistinguishable from a sign-in and makes the evicted (stood-down) instance
3054
+ // re-dial → it evicts the other device → which later re-mints and re-dials →
3055
+ // forever. The user sees app.galda.app reload-looping (the "無限ループ").
3056
+ //
3057
+ // Fix: once we're evicted (4000) we go stoodDown and remember WHICH identity we
3058
+ // were bound to. A token change for the SAME identity (a re-mint of the account
3059
+ // already live on the other device) must NOT re-dial. Only a genuinely DIFFERENT
3060
+ // identity (the user switched accounts on this device) or an explicit takeover
3061
+ // (the "switch to this device" button, P4) re-dials. Newest instance wins, and
3062
+ // the loser stays down through any number of re-mints.
3063
+
3064
+ // Pull the account identity out of a compact license token WITHOUT verifying the
3065
+ // signature — we only need a stable local key to compare "same account?". Format
3066
+ // is b64url(header).b64url(payload).b64url(sig) with payload.email (see
3067
+ // workers/billing-api/src/license.mjs). Returns '' when it can't be decoded.
3068
+ export function licenseTokenIdentity(token) {
3069
+ if (!token || typeof token !== 'string') return '';
3070
+ const parts = token.split('.');
3071
+ if (parts.length !== 3) return '';
3072
+ try {
3073
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
3074
+ const email = payload?.email;
3075
+ return typeof email === 'string' ? email.trim().toLowerCase() : '';
3076
+ } catch { return ''; }
3077
+ }
3078
+
3079
+ // Pure reducer for the relay-client's reconnect decision. Returns the next state
3080
+ // and whether to dial the relay now. State: { stoodDown, standingIdentity }.
3081
+ // Events:
3082
+ // { type: 'evicted', identity } — relay closed us with 4000 (superseded)
3083
+ // { type: 'dropped' } — a normal connection drop (network etc.)
3084
+ // { type: 'tokenChanged', identity }— license.token appeared/changed on disk
3085
+ // { type: 'takeover' } — user explicitly claimed this device (P4)
3086
+ export function relayReconnectDecision(state, event) {
3087
+ const s = { stoodDown: Boolean(state?.stoodDown), standingIdentity: state?.standingIdentity ?? null };
3088
+ switch (event?.type) {
3089
+ case 'evicted':
3090
+ // A newer instance of THIS account took the slot — stand down and record
3091
+ // whose slot we lost, so a later re-mint of the same account won't re-dial.
3092
+ return { state: { stoodDown: true, standingIdentity: event.identity ?? s.standingIdentity }, dial: false, reason: 'superseded' };
3093
+ case 'dropped':
3094
+ // Ordinary drop: reconnect, UNLESS we already stood down (never re-enter
3095
+ // the ring while another device holds the slot).
3096
+ return { state: s, dial: !s.stoodDown, reason: s.stoodDown ? 'stood-down' : 'reconnect' };
3097
+ case 'tokenChanged': {
3098
+ const id = event.identity || '';
3099
+ if (s.stoodDown && id && id === s.standingIdentity) {
3100
+ // Same account re-minted its token — the other device is still it. Stay
3101
+ // down; this is the exact re-dial that caused the infinite loop.
3102
+ return { state: s, dial: false, reason: 'same-account-remint' };
3103
+ }
3104
+ // A different account (real account switch) or first sign-in → clear the
3105
+ // stand-down and dial with the new identity.
3106
+ return { state: { stoodDown: false, standingIdentity: null }, dial: true, reason: 'identity-changed' };
3107
+ }
3108
+ case 'takeover':
3109
+ // User pressed "switch to this device" — reclaim the slot on purpose.
3110
+ return { state: { stoodDown: false, standingIdentity: null }, dial: true, reason: 'takeover' };
3111
+ default:
3112
+ return { state: s, dial: false, reason: 'noop' };
3113
+ }
3114
+ }