@galda/cli 0.10.17 → 0.10.20
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 +43 -1
- package/app/index.html +376 -62
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +492 -12
- package/engine/relay-client.mjs +137 -32
- package/engine/server.mjs +445 -21
- package/package.json +3 -3
package/bin/manager-for-ai.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
say('
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
const
|
|
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
|
-
|
|
219
|
-
if (
|
|
220
|
-
if (usedHandoff) techniques.push('
|
|
221
|
-
if (usedCompact) techniques.push('/compact');
|
|
222
|
-
if (usedClear) techniques.push('/clear');
|
|
223
|
-
if (agentModel) techniques.push(
|
|
224
|
-
if (
|
|
225
|
-
|
|
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,93 @@ 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
|
+
}
|
|
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
|
+
}
|