@galda/cli 0.10.34 → 0.10.36

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.
@@ -118,8 +118,18 @@ await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
118
118
  // public build will default it to the deployed relay. See relay/DEPLOY.md.
119
119
  if (process.env.RELAY_URL) {
120
120
  const { spawn } = await import('node:child_process');
121
- const relay = spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env });
122
- const stop = () => { try { relay.kill(); } catch { /* already gone */ } };
121
+ const { superviseRelay } = await import(pathToFileURL(join(ROOT, 'engine', 'relay-supervisor.mjs')).href);
122
+ // Supervise the relay-client: if the CHILD exits (uncaught throw, single-
123
+ // instance lock refuse) we bring it back with backoff, otherwise the app goes
124
+ // silently offline ("Unsent"). A tight crash loop (a config/lock error) trips a
125
+ // circuit breaker so we stop hammering and say why. relay-client heals its own
126
+ // dropped socket; this heals the whole process dying. See engine/relay-supervisor.mjs.
127
+ const sup = superviseRelay({
128
+ spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
129
+ log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
130
+ onGiveUp: () => say('relay-client keeps exiting immediately — giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
131
+ });
132
+ const stop = () => sup.stop();
123
133
  process.on('exit', stop);
124
134
  for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stop(); process.exit(0); });
125
135
  say(`relay: connecting to ${process.env.RELAY_URL} — your fixed URL goes live once you sign in`);
package/engine/lib.mjs CHANGED
@@ -906,6 +906,73 @@ export function hasTestRelevantChanges(changedFiles) {
906
906
  return files.some((f) => !DOC_ONLY_RE.test(f));
907
907
  }
908
908
 
909
+ // Extract the set of FAILING test titles from a `node --test` (or TAP) run.
910
+ // The gate needs NAMES, not just a count, so it can tell a NEW failure (this
911
+ // change's fault) from one that was ALREADY red on the base (e.g. a known-red
912
+ // like persist-failure that fails even on clean origin/main — CLAUDE.md).
913
+ // node --test prints `✖ <title> (1234.5ms)` (both inline and again under the
914
+ // `✖ failing tests:` summary header, which carries no title and is skipped);
915
+ // TAP prints `not ok N - <title>` (a `# SKIP`/`# TODO` directive is not a
916
+ // failure). Titles are de-duplicated and sorted for a stable set-diff.
917
+ export function parseFailingTestNames(text) {
918
+ const out = new Set();
919
+ const stripMs = (s) => s.replace(/\s*\(\d[\d.]*m?s\)\s*$/i, '').trim();
920
+ for (const raw of String(text ?? '').split('\n')) {
921
+ const line = raw.trim();
922
+ if (!line) continue;
923
+ let m = line.match(/^[✖✗✘×]\s+(.*)$/);
924
+ if (m) {
925
+ const title = stripMs(m[1].trim());
926
+ if (!title || /^failing tests:?$/i.test(title)) continue; // summary header
927
+ out.add(title);
928
+ continue;
929
+ }
930
+ m = line.match(/^not ok\s+\d+\s*-?\s*(.*)$/i);
931
+ if (m) {
932
+ if (/#\s*(SKIP|TODO)\b/i.test(line)) continue; // directive, not a real failure
933
+ const title = stripMs(m[1].replace(/\s+#.*$/, '').trim());
934
+ if (title) out.add(title);
935
+ }
936
+ }
937
+ return [...out].sort();
938
+ }
939
+
940
+ // Compare the CURRENT run's failing tests against the BASE (pre-change) run's
941
+ // so the review gate blocks ONLY on failures this change introduced. A test
942
+ // that was already red on the base is pre-existing noise (unrelated to the
943
+ // goal) — it must not knock a proof-verified goal back to blocked/Failed.
944
+ // Callers should treat an EMPTY baseline as "unknown" and fall back to the old
945
+ // block-on-any-red behaviour (fail closed) — see verifyGate.
946
+ export function classifyTestGate({ current = [], baseline = [] } = {}) {
947
+ const base = new Set(baseline);
948
+ const cur = [...new Set((current ?? []).filter(Boolean))];
949
+ const newFailures = cur.filter((t) => !base.has(t)).sort();
950
+ const preExisting = cur.filter((t) => base.has(t)).sort();
951
+ return { newFailures, preExisting, blocked: newFailures.length > 0 };
952
+ }
953
+
954
+ // The verification-gate decision (Masa 2026-07-19: "検証/proof は Failed を作らない").
955
+ // PROOF IS ADVISORY: a UI goal whose screenshot could not be captured
956
+ // (proofMissing) or whose pixel heuristic could not auto-confirm the target
957
+ // (proofFailing) is NOT blocked — the work is done and reaches Review with a
958
+ // note; proof adds confidence, it never subtracts a correct result. What still
959
+ // blocks a goal from Review: newly-failing tests (testsFailing — the caller has
960
+ // already made this baseline-aware via classifyTestGate), a promised-but-undone
961
+ // task (nothingVerifiable = a passCondition with no file change and no proof),
962
+ // and the opt-in requireVerifyPass gate (verifyFail, off by default). Pure so
963
+ // the whole gate decision is unit-tested rather than living only inside the
964
+ // server's big async verifyGate.
965
+ export function classifyVerifyGate({
966
+ testsFailing = false, proofMissing = false, proofFailing = false,
967
+ nothingVerifiable = false, verifyFail = false,
968
+ } = {}) {
969
+ const blocked = Boolean(testsFailing || nothingVerifiable || verifyFail);
970
+ const kind = testsFailing ? 'test' : nothingVerifiable ? 'noop' : verifyFail ? 'verify' : null;
971
+ // Advisory note surfaced on the review even when the goal is NOT blocked.
972
+ const proofAdvisory = proofMissing ? 'missing' : proofFailing ? 'unconfirmed' : null;
973
+ return { blocked, kind, proofAdvisory };
974
+ }
975
+
909
976
  // Whether to ALSO capture a BASELINE ("before") shot for a before/after
910
977
  // comparison. Only worth attempting when the baseline is a genuinely
911
978
  // different checkout of the code than the one just captured as "after" — a
@@ -1308,6 +1375,22 @@ export function classifyComposerIntentHeuristic(text) {
1308
1375
  return 'unsure'; // both or neither → let the LLM decide
1309
1376
  }
1310
1377
 
1378
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
1379
+ // conservative match — no LLM, no cost — that only fires when the WHOLE message
1380
+ // is a short, command-like pause/resume order. Anchored ^…$ with only polite
1381
+ // suffixes allowed after the keyword, plus a length cap, so a described request
1382
+ // that merely contains the word ("止めてほしいバグを直して", "start building the
1383
+ // login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
1384
+ const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
1385
+ const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
1386
+ export function detectPauseIntent(text) {
1387
+ const t = (text || '').trim();
1388
+ if (!t || t.length > 24) return null; // a control command is short; long text is a real request
1389
+ if (PAUSE_INTENT_RE.test(t)) return 'pause';
1390
+ if (RESUME_INTENT_RE.test(t)) return 'resume';
1391
+ return null;
1392
+ }
1393
+
1311
1394
  // The one prompt that BOTH classifies and (for chat) writes the reply in the
1312
1395
  // requester's language — one Haiku round-trip, not two. The model returns a
1313
1396
  // single line of JSON: {"intent":"chat|task","reply":"…"}.
@@ -1907,7 +1990,42 @@ export const WORKFLOW_BUCKETS = ['todo', 'doing', 'review', 'done'];
1907
1990
  // Tools a `claude -p` worker may call (--allowedTools). TodoWrite must stay
1908
1991
  // in this list — it's how the UI's To-do section (sendTodos / SSE 'todos')
1909
1992
  // gets any data at all; without it workers can never emit {kind:'todos'}.
1910
- export const WORKER_TOOLS = 'Edit,Write,Read,Glob,Grep,Skill,TodoWrite,Bash(node:*),Bash(npm:*),Bash(npx:*)';
1993
+ //
1994
+ // Bash was locked to node/npm/npx, which produced the "works in Claude Code,
1995
+ // fails here" class: a goal that legitimately needs python, a build tool, or
1996
+ // even a read-only `git log` would just fail — an error a plain Claude Code
1997
+ // session never hits. Widened here to the common legitimate commands.
1998
+ //
1999
+ // Honest note on what this list does and does NOT buy: it is friction control,
2000
+ // not a security boundary. Bash(node:*) already permits arbitrary execution
2001
+ // (`node -e "..."` can touch the network, delete files, anything), so the old
2002
+ // narrow list contained nothing — it only blocked honest direct commands.
2003
+ // Real isolation would need an OS sandbox (codex runs with --sandbox; the
2004
+ // claude-code worker does not). Given that, the exclusions below are posture,
2005
+ // not a wall:
2006
+ // - git WRITE ops (commit/push/reset/checkout) are left off the fast path so
2007
+ // the worker does not fight the Manager, which owns git in the worktree
2008
+ // (the worker prompt already says "do not commit"). Read-only git is on.
2009
+ // - rm / curl / wget stay off the fast path. (They are reachable via node
2010
+ // anyway; keeping them non-first-class is a stated-posture choice. Whether
2011
+ // to simplify to a blanket `Bash` — technically equivalent risk — is a
2012
+ // customer-safety decision for Masa, not a silent flip here.)
2013
+ const WORKER_BASH = [
2014
+ // JS/TS runtimes and package managers
2015
+ 'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'deno',
2016
+ // other languages a task might actually need
2017
+ 'python', 'python3', 'pip', 'pip3', 'pytest', 'go', 'cargo', 'rustc',
2018
+ 'ruby', 'bundle', 'make',
2019
+ // build/test/lint runners invoked directly
2020
+ 'tsc', 'jest', 'vitest', 'eslint', 'prettier',
2021
+ // read-only git — inspect history/diffs like you would locally
2022
+ 'git status', 'git log', 'git diff', 'git show', 'git branch',
2023
+ 'git blame', 'git ls-files', 'git rev-parse',
2024
+ // read/inspect shell utilities (file EDITS still go through Edit/Write)
2025
+ 'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
2026
+ 'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
2027
+ ].map((c) => `Bash(${c}:*)`).join(',');
2028
+ export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
1911
2029
 
1912
2030
  // ---- SKILL picker ----------------------------------------------------------
1913
2031
  // Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
@@ -0,0 +1,113 @@
1
+ // Launcher-side supervisor for the relay-client CHILD process.
2
+ //
3
+ // Why this exists (2026-07-18 incident): the launcher (bin/manager-for-ai.mjs)
4
+ // spawns engine/relay-client.mjs exactly ONCE and only kills it when the parent
5
+ // goes down — there was no path the other way (child dies -> bring it back).
6
+ // relay-client already self-heals a dropped SOCKET (backoff reconnect, capped at
7
+ // 15s), but when the whole PROCESS exits — an uncaught throw, or the single-
8
+ // instance lock refusing to start — nothing respawns it, so the user's app
9
+ // silently goes offline and every new goal shows "Unsent – tap to resend". This
10
+ // is the customer-facing half of the fix (the operator's own box is now kept up
11
+ // by a launchd KeepAlive job; npx users have no launchd, only this launcher).
12
+ //
13
+ // Split into a PURE policy reducer (relayRespawnDecision) and an impure driver
14
+ // (superviseRelay) that owns spawn()/timers/clock — same shape as
15
+ // relayReconnectDecision in engine/lib.mjs. The policy is unit-tested without any
16
+ // real process; the driver is integration-tested with a fake child.
17
+
18
+ // Fresh supervisor state. `delayMs` is the last backoff used; `quickExits` counts
19
+ // consecutive exits that happened inside the "quick-crash" window; `gaveUp` latches
20
+ // once the circuit breaker trips.
21
+ export const relaySupervisorInit = { delayMs: 0, quickExits: 0, gaveUp: false };
22
+
23
+ // Decide what to do when the supervised child exits. Pure — no clock, no I/O.
24
+ //
25
+ // state : previous supervisor state (start from relaySupervisorInit)
26
+ // event : { uptimeMs } — how long the child that just exited had been alive
27
+ // opts : tunables (defaults match the relay socket backoff feel)
28
+ // minMs floor delay for the first / a reset respawn (1s)
29
+ // maxMs ceiling the exponential backoff saturates at (15s)
30
+ // quickMs a child that lived less than this "crash-looped" (10s)
31
+ // limit consecutive quick exits before we give up (5)
32
+ //
33
+ // Returns { restart, delayMs, gaveUp, state }.
34
+ // - A child that lived past `quickMs` is treated as having made progress: the
35
+ // backoff and the quick-exit counter both reset (a healthy long run should not
36
+ // inherit a punishing delay from an earlier bad patch).
37
+ // - Consecutive quick exits escalate the delay (min, 2x, 4x, ... capped at max)
38
+ // and, once they reach `limit`, trip the circuit breaker so we stop hammering a
39
+ // config/lock error forever. The caller logs why and stands down.
40
+ export function relayRespawnDecision(state, event, opts = {}) {
41
+ const { minMs = 1000, maxMs = 15000, quickMs = 10000, limit = 5 } = opts;
42
+ const prev = Number(state?.delayMs);
43
+ const lastDelay = Number.isFinite(prev) ? prev : 0;
44
+ const uptimeMs = Number(event?.uptimeMs) || 0;
45
+ const quick = uptimeMs < quickMs;
46
+ const quickExits = quick ? (Number(state?.quickExits) || 0) + 1 : 0;
47
+
48
+ if (quickExits >= limit) {
49
+ return { restart: false, delayMs: 0, gaveUp: true, state: { delayMs: lastDelay, quickExits, gaveUp: true } };
50
+ }
51
+
52
+ const delayMs = quick
53
+ ? (lastDelay < minMs ? minMs : Math.min(lastDelay * 2, maxMs))
54
+ : minMs;
55
+ return { restart: true, delayMs, gaveUp: false, state: { delayMs, quickExits, gaveUp: false } };
56
+ }
57
+
58
+ // Drive a respawning child. Impure: owns spawn()/timers/clock, all injectable so
59
+ // the loop is testable with a fake child and fast delays.
60
+ //
61
+ // spawn () => ChildProcess-like (must emit 'exit' and have .kill())
62
+ // now () => ms clock (default Date.now)
63
+ // setTimer/clearTimer timer fns (default global setTimeout/clearTimeout)
64
+ // log ({ code, signal, delayMs }) => void — a respawn was scheduled
65
+ // onGiveUp () => void — circuit breaker tripped
66
+ // opts passed through to relayRespawnDecision
67
+ //
68
+ // Returns { stop, child, state } — stop() suppresses further respawns (normal
69
+ // shutdown) and kills the current child.
70
+ export function superviseRelay({
71
+ spawn,
72
+ now = () => Date.now(),
73
+ setTimer = setTimeout,
74
+ clearTimer = clearTimeout,
75
+ log = () => {},
76
+ onGiveUp = () => {},
77
+ opts,
78
+ } = {}) {
79
+ let shuttingDown = false;
80
+ let state = { ...relaySupervisorInit };
81
+ let child = null;
82
+ let startedAt = 0;
83
+ let timer = null;
84
+
85
+ const start = () => {
86
+ startedAt = now();
87
+ child = spawn();
88
+ child.on('exit', (code, signal) => {
89
+ child = null;
90
+ if (shuttingDown) return; // parent asked us to stop — don't fight it
91
+ const uptimeMs = now() - startedAt;
92
+ const decision = relayRespawnDecision(state, { uptimeMs }, opts);
93
+ state = decision.state;
94
+ if (!decision.restart) { onGiveUp(); return; }
95
+ log({ code, signal, delayMs: decision.delayMs });
96
+ timer = setTimer(start, decision.delayMs);
97
+ if (timer && typeof timer.unref === 'function') timer.unref();
98
+ });
99
+ };
100
+
101
+ const stop = () => {
102
+ shuttingDown = true;
103
+ if (timer) { clearTimer(timer); timer = null; }
104
+ try { child?.kill(); } catch { /* already gone */ }
105
+ };
106
+
107
+ start();
108
+ return {
109
+ stop,
110
+ get child() { return child; },
111
+ get state() { return state; },
112
+ };
113
+ }