@galda/cli 0.10.16 → 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.
- package/CLAUDE.md +30 -1
- package/app/index.html +418 -53
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +536 -12
- package/engine/relay-client.mjs +101 -32
- package/engine/server.mjs +478 -20
- package/package.json +2 -2
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 } = {}) {
|
|
@@ -1170,6 +1274,72 @@ export function detectRequestLanguage(text) {
|
|
|
1170
1274
|
return /[-ヿ㐀-鿿]/.test(text || '') ? 'ja' : 'en';
|
|
1171
1275
|
}
|
|
1172
1276
|
|
|
1277
|
+
// SLICE 2 intent routing (docs/HANDOFF-ONBOARDING-conversational.md): the single
|
|
1278
|
+
// composer is conversation-first — a chat/help/settings message ("what can you
|
|
1279
|
+
// do?", "reply in English", a greeting) must get a conversational reply, NOT a
|
|
1280
|
+
// To-Do; only implementation requests become goals. These are the pure,
|
|
1281
|
+
// testable pieces; the server wires them into POST /api/tasks.
|
|
1282
|
+
//
|
|
1283
|
+
// A dependency-free heuristic that short-circuits only the OBVIOUS cases so most
|
|
1284
|
+
// sends skip the LLM classifier:
|
|
1285
|
+
// 'task' — a clear implementation request (imperative verb / code token) and
|
|
1286
|
+
// no chat signal → skip the LLM, go straight to goal creation.
|
|
1287
|
+
// 'chat' — a clear help/settings/greeting message and no task signal.
|
|
1288
|
+
// 'unsure' — ambiguous (both signals, or neither) → defer to the LLM.
|
|
1289
|
+
// It is deliberately conservative about declaring 'task' (the only branch that
|
|
1290
|
+
// skips the LLM): a message carrying BOTH a task verb and a help/settings signal
|
|
1291
|
+
// is 'unsure', not 'task', so a help question phrased with "add"/"fix" isn't
|
|
1292
|
+
// silently turned into a goal.
|
|
1293
|
+
const INTENT_TASK_RE = /\b(fix|add|implement|create|build|refactor|remove|delete|rename|migrate|debug|integrate|revert|deploy)\b|実装|直し|直す|なおして|修正|作って|作成|追加|変更|削除|対応して|リファクタ|置き換え|バグ|不具合/i;
|
|
1294
|
+
const INTENT_CODE_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|css|scss|html|py|go|rs|java|rb|php|json|md|yml|yaml|sh|sql)\b|`[^`]+`|\b(?:function|const|class|import)\b|#\d+/;
|
|
1295
|
+
const INTENT_CHAT_META_RE = /何ができ|なにができ|何が出来|使い方|どう使|どうやって使|使える\?|説明して|教えて(?:ください|くれ|ほしい)?\s*[??]?$|about this (?:tool|app)|what (?:can|do|are|is|does) (?:you|it|this|i)|what'?s this|what can this|how (?:do|does|can) (?:i|this|you|it)|how to use|who are you|^\s*help\s*[??]?$|そもそも|初めて/i;
|
|
1296
|
+
const INTENT_CHAT_SETTINGS_RE = /英語で|日本語で|(?:reply|respond|answer|talk|speak|write)\s+(?:back\s+)?in\s+\w|語で(?:返|答|話|書)|言語/i;
|
|
1297
|
+
const INTENT_GREETING_RE = /^\s*(?:hi|hey|hello|yo|sup|thanks|thank you|thx|good (?:morning|evening|afternoon))\b[\s.!?]*$|^\s*(?:こんにち(?:は|わ)|こんばん(?:は|わ)|おはよう|はじめまして|やあ|ありがと(?:う)?|よろしく)[\s。!?!?]*$/i;
|
|
1298
|
+
|
|
1299
|
+
export function classifyComposerIntentHeuristic(text) {
|
|
1300
|
+
const t = (text || '').trim();
|
|
1301
|
+
if (!t) return 'chat';
|
|
1302
|
+
const hasTask = INTENT_TASK_RE.test(t) || INTENT_CODE_RE.test(t);
|
|
1303
|
+
const hasChat = INTENT_CHAT_META_RE.test(t) || INTENT_CHAT_SETTINGS_RE.test(t) || INTENT_GREETING_RE.test(t);
|
|
1304
|
+
if (hasTask && !hasChat) return 'task'; // pure implementation request → skip LLM
|
|
1305
|
+
if (hasChat && !hasTask) return 'chat'; // pure help/settings/greeting
|
|
1306
|
+
return 'unsure'; // both or neither → let the LLM decide
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// The one prompt that BOTH classifies and (for chat) writes the reply in the
|
|
1310
|
+
// requester's language — one Haiku round-trip, not two. The model returns a
|
|
1311
|
+
// single line of JSON: {"intent":"chat|task","reply":"…"}.
|
|
1312
|
+
export function buildIntentPrompt(text, lang) {
|
|
1313
|
+
const language = lang === 'ja' ? '日本語' : 'English';
|
|
1314
|
+
return [
|
|
1315
|
+
'You are the intent router for Agent Manager: a tool where a user delegates a coding task in plain language, an AI implements it, and it comes back as a reviewed pull request with proof.',
|
|
1316
|
+
'Classify the user message below into exactly one intent:',
|
|
1317
|
+
'- "task": a request to implement, change, fix, build, or remove something in code (even if vague, e.g. "add dark mode").',
|
|
1318
|
+
'- "chat": anything that is NOT an implementation request — questions about what this tool can do or how to use it, greetings, thanks, small talk, or a settings/preference request (e.g. "reply in English").',
|
|
1319
|
+
'When in doubt between the two, prefer "task".',
|
|
1320
|
+
`If intent is "chat", also write "reply": a short, warm, helpful answer in ${language} (at most 4 sentences). If the user asks what this can do or how to start, briefly explain: describe the change you want in plain language, and it will ask which folder/repo to work in, implement it, and return a PR with proof. If intent is "task", set "reply" to "".`,
|
|
1321
|
+
'Output ONLY one line of raw JSON with keys "intent" and "reply". No markdown, no code fence, no extra text.',
|
|
1322
|
+
'',
|
|
1323
|
+
'User message:',
|
|
1324
|
+
String(text || '').slice(0, 4000),
|
|
1325
|
+
].join('\n');
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Tolerant parser for the router's reply — never throws. Extracts the first JSON
|
|
1329
|
+
// object; on any failure it fails safe to 'task' (so a garbled classification
|
|
1330
|
+
// can never silently swallow a real implementation request). Reply is clamped.
|
|
1331
|
+
export function parseIntentResponse(raw) {
|
|
1332
|
+
const fallback = { intent: 'task', reply: '' };
|
|
1333
|
+
if (!raw || typeof raw !== 'string') return fallback;
|
|
1334
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
1335
|
+
if (!m) return fallback;
|
|
1336
|
+
let obj;
|
|
1337
|
+
try { obj = JSON.parse(m[0]); } catch { return fallback; }
|
|
1338
|
+
const intent = obj && obj.intent === 'chat' ? 'chat' : 'task';
|
|
1339
|
+
const reply = intent === 'chat' && typeof obj.reply === 'string' ? obj.reply.trim().slice(0, 2000) : '';
|
|
1340
|
+
return { intent, reply };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1173
1343
|
// Normalize a nested boolean-flag object (prSections/reviewCard) key by key,
|
|
1174
1344
|
// so a partial payload — or one missing the whole sub-object — can't
|
|
1175
1345
|
// silently disable flags it never mentioned; each key falls back to its own
|
|
@@ -1600,6 +1770,94 @@ export function reconcileOrphanGoals(goals, tasks) {
|
|
|
1600
1770
|
return out;
|
|
1601
1771
|
}
|
|
1602
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
|
+
|
|
1603
1861
|
// The four states the engine actually tracks per goal/task (see
|
|
1604
1862
|
// goalGroupStatus/goalChip above). A project's workflow columns are a
|
|
1605
1863
|
// display-layer relabeling/reordering on top of these — every column must
|
|
@@ -1857,7 +2115,7 @@ export function shouldShowGoalSummary(goal) {
|
|
|
1857
2115
|
// Mirrored in app/index.html (buildReviewSummary) since the inline browser
|
|
1858
2116
|
// script can't import this module. No LLM — composed from goal.plan / task
|
|
1859
2117
|
// titles / task状態.
|
|
1860
|
-
export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
|
|
2118
|
+
export function buildReviewSummary({ goal, tasks, approvedN = 0, tokenHistory = [] } = {}) {
|
|
1861
2119
|
const reqs = (tasks ?? []).filter((t) => !t.reply);
|
|
1862
2120
|
const total = reqs.length;
|
|
1863
2121
|
const done = reqs.filter((t) => ['done', 'skipped'].includes(t.status)).length;
|
|
@@ -1886,10 +2144,26 @@ export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
|
|
|
1886
2144
|
usedCompact: goal?.executionPlan?.sessionMode === 'compact',
|
|
1887
2145
|
usedClear: Boolean(goal?.sessionClosedAt),
|
|
1888
2146
|
agentModel: goal?.model,
|
|
2147
|
+
requirementCount: total,
|
|
2148
|
+
history: tokenHistory,
|
|
1889
2149
|
}),
|
|
1890
2150
|
};
|
|
1891
2151
|
}
|
|
1892
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
|
+
|
|
1893
2167
|
// Task 114: the persistent bottom-right Log box (formerly the running-task-only
|
|
1894
2168
|
// #actpanel) defaults to collapsed (1 line) and expands on click. Plain boolean
|
|
1895
2169
|
// flip, but pulled out as a pure function so the click handler has a unit test
|
|
@@ -2575,6 +2849,188 @@ export function isFolderInitConfirmed(answer, question = {}) {
|
|
|
2575
2849
|
return /^(yes|y|はい|ok|する|始める)$/i.test(a);
|
|
2576
2850
|
}
|
|
2577
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
|
+
|
|
2578
3034
|
// Analytics identity (A+): a signed-in install scopes usage events to the
|
|
2579
3035
|
// ACCOUNT (its email, which the billing Worker hashes into the same account_id
|
|
2580
3036
|
// the accounts table carries) so per-user app activity joins WITHOUT a cookie;
|
|
@@ -2588,3 +3044,71 @@ export function pickAnalyticsUid(accountEmail, installId) {
|
|
|
2588
3044
|
export function shouldEmitFreeExhausted(alreadyEmitted, blocked) {
|
|
2589
3045
|
return !alreadyEmitted && Boolean(blocked);
|
|
2590
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
|
+
}
|