@galda/cli 0.10.2 → 0.10.7
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 +6 -0
- package/app/index.html +26 -9
- package/bin/manager-for-ai.mjs +34 -1
- package/engine/analytics-client.mjs +3 -1
- package/engine/lib.mjs +24 -0
- package/engine/mcp.mjs +4 -1
- package/engine/relay-client.mjs +52 -6
- package/engine/server.mjs +34 -4
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
- 探索的な読み込み(横断 grep・場所探し・大きいファイル通読)は**既定で subagent に逃がし**、メイン文脈に全文を載せない。ピンポイントな1関数だけ直接読む。
|
|
34
34
|
- 別エージェントへ渡す時は、チャット履歴でなく **1枚の handoff md** を書いて渡す。
|
|
35
35
|
|
|
36
|
+
## デプロイ/インフラの掟(Masa明言 2026-07-15・全レーン必読)
|
|
37
|
+
|
|
38
|
+
- **`a2c-tech.workers.dev` を絶対に使わない・コードやdocsに書かない**。これはクライアント企業 **A2C** の名を冠した Cloudflare workers.dev サブドメインで、Galda の製品・URL・設定に一切登場させてはいけない(見つけたら全て除去)。
|
|
39
|
+
- Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
|
|
40
|
+
- **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
|
|
41
|
+
|
|
36
42
|
## 構成
|
|
37
43
|
|
|
38
44
|
- `engine/server.mjs` … チャットアプリのサーバ(:4400・タスク分解・worker実行・SSE・PR作成)
|
package/app/index.html
CHANGED
|
@@ -1239,15 +1239,16 @@
|
|
|
1239
1239
|
#fsRoot .nowdoing .nd-pct{margin-left:auto;font:600 11px/1 var(--mono);color:var(--ink2);font-variant-numeric:tabular-nums;flex:0 0 auto}
|
|
1240
1240
|
#fsRoot .nowdoing .nd-bar{flex:1 0 100%;height:2px;border-radius:2px;background:var(--hair);overflow:hidden}
|
|
1241
1241
|
#fsRoot .nowdoing .nd-bar i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,var(--edgeA,#C7B5FF),var(--edgeB,#7C9EFF))}
|
|
1242
|
-
/* H19/§8-2
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1242
|
+
/* was H19/§8-2 bottom-anchor (#fsFeed{margin-top:auto}): sparse/initial content sat
|
|
1243
|
+
glued to the composer with a big blank gap above it — read as "text appears from
|
|
1244
|
+
the bottom" (Masa bug report 2026-07-15, recurrence of an earlier fix). Feed now
|
|
1245
|
+
always starts at the top of .fsstream like a normal chat log; new lines still
|
|
1246
|
+
auto-follow to the bottom via the nearBottom check in renderFsFeed(). Header
|
|
1247
|
+
(.chd/.nowdoing) stays fixed above. */
|
|
1247
1248
|
#fsRoot .fsstream{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;max-width:var(--fscenterw,760px);width:100%;margin:0 auto;scrollbar-width:none;position:relative;z-index:1;
|
|
1248
1249
|
transition:max-width .28s cubic-bezier(.4,0,.2,1)}
|
|
1249
1250
|
#fsRoot .fsstream::-webkit-scrollbar{display:none}
|
|
1250
|
-
#fsFeed{display:flex;flex-direction:column;gap:8px;padding-top:2px
|
|
1251
|
+
#fsFeed{display:flex;flex-direction:column;gap:8px;padding-top:2px}
|
|
1251
1252
|
#fsRoot .cc-say{font-size:13.5px;color:var(--ink)}
|
|
1252
1253
|
/* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
|
|
1253
1254
|
own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
|
|
@@ -2974,7 +2975,7 @@ function taskBlock(t){
|
|
|
2974
2975
|
const lines = state.act[t.id] ?? [];
|
|
2975
2976
|
body = `<div class="pbar"><i style="width:${progressFor(t)}%"></i></div><div class="pmeta">${progressMeta(t)}</div>
|
|
2976
2977
|
${todosHtml(t)}
|
|
2977
|
-
<div class="tres runlog">${lines.map((l) => `<div class="actline">${esc(l)}</div>`).join('') || '<span class="actline">starting…</span>'}</div>`;
|
|
2978
|
+
<div class="tres runlog" data-runlog="${t.id}">${lines.map((l) => `<div class="actline">${esc(l)}</div>`).join('') || '<span class="actline">starting…</span>'}</div>`;
|
|
2978
2979
|
} else if (t.status === 'done') {
|
|
2979
2980
|
const files = t.changedFiles?.length ? `<div class="files"><b>changed:</b> ${t.changedFiles.map(esc).join(' · ')}</div>` : '';
|
|
2980
2981
|
const usage = formatUsage(t.usage);
|
|
@@ -3117,6 +3118,15 @@ function renderStream(){
|
|
|
3117
3118
|
// render never yanks the view while they read old history.
|
|
3118
3119
|
const wasNearBottom = streamEl.scrollHeight - streamEl.clientHeight - streamEl.scrollTop <= 100;
|
|
3119
3120
|
const keepScroll = streamEl.scrollTop;
|
|
3121
|
+
// Mirror of engine/lib.mjs nextRunlogScrollTop(): a running task's live log
|
|
3122
|
+
// (.tres.runlog) has its OWN scroll region and its HTML is rebuilt every SSE
|
|
3123
|
+
// render. Capture each one's geometry (keyed by task id) BEFORE the swap so we
|
|
3124
|
+
// can keep a user who scrolled up to re-read earlier lines pinned there instead
|
|
3125
|
+
// of yanking them to the bottom on every incoming activity line.
|
|
3126
|
+
const runlogPre = new Map();
|
|
3127
|
+
for (const el of streamEl.querySelectorAll('[data-runlog]')) {
|
|
3128
|
+
runlogPre.set(el.dataset.runlog, { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
|
3129
|
+
}
|
|
3120
3130
|
const items = mergeGoalTimeline(state.goals, state.externalActivity, state.active);
|
|
3121
3131
|
// never-lose layer: memos not yet accepted by the server render from the
|
|
3122
3132
|
// local outbox — visible, resendable, never silently gone
|
|
@@ -3168,7 +3178,10 @@ function renderStream(){
|
|
|
3168
3178
|
inp.disabled = false;
|
|
3169
3179
|
});
|
|
3170
3180
|
}
|
|
3171
|
-
for (const el of $('stream').querySelectorAll('
|
|
3181
|
+
for (const el of $('stream').querySelectorAll('[data-runlog]')) {
|
|
3182
|
+
const pre = runlogPre.get(el.dataset.runlog) ?? null;
|
|
3183
|
+
el.scrollTop = !pre || (pre.scrollHeight - pre.clientHeight - pre.scrollTop <= 100) ? el.scrollHeight : pre.scrollTop;
|
|
3184
|
+
}
|
|
3172
3185
|
if (wasNearBottom) streamEl.scrollTop = streamEl.scrollHeight;
|
|
3173
3186
|
else streamEl.scrollTop = keepScroll; // reading history: never yank the view
|
|
3174
3187
|
}
|
|
@@ -5752,7 +5765,11 @@ function bootDisconnected(info){
|
|
|
5752
5765
|
const gate = (e) => { if (e) e.preventDefault(); open(); };
|
|
5753
5766
|
if (input) { input.readOnly = true; input.addEventListener('focus', gate); input.addEventListener('mousedown', gate); input.addEventListener('keydown', gate); }
|
|
5754
5767
|
if (send) send.addEventListener('click', gate);
|
|
5755
|
-
|
|
5768
|
+
// Keep the agent / model / effort pickers live so Codex users can switch to
|
|
5769
|
+
// Codex (and see it's a first-class option) even before this device connects;
|
|
5770
|
+
// only typing a goal or hitting send opens the connect explainer. (Masa 2026-07-15)
|
|
5771
|
+
const cw = document.getElementById('composerWrap');
|
|
5772
|
+
if (cw) cw.addEventListener('mousedown', (e) => { if (e.target.closest('#appsel, #modelsel, #effortsel')) return; gate(e); });
|
|
5756
5773
|
|
|
5757
5774
|
// come alive automatically: when the relay stops flagging disconnected (the
|
|
5758
5775
|
// engine connected → its live app is proxied), reload into the real board.
|
package/bin/manager-for-ai.mjs
CHANGED
|
@@ -10,9 +10,26 @@ import { spawnSync } from 'node:child_process';
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { join, resolve, dirname } from 'node:path';
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
+
import { createServer } from 'node:net';
|
|
13
14
|
|
|
14
15
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
16
|
const say = (m) => console.log(`[galda] ${m}`);
|
|
17
|
+
|
|
18
|
+
// Pick a free local port (starting at the requested one) so `npx @galda/cli`
|
|
19
|
+
// JUST WORKS even when the default 4400 is already taken — e.g. another manager
|
|
20
|
+
// is running on this machine. We do it HERE, before importing the server and
|
|
21
|
+
// spawning the relay-client, and export MANAGER_PORT, so both halves agree on
|
|
22
|
+
// the same port (the relay-client reads MANAGER_PORT too). No manual override.
|
|
23
|
+
const canBind = (p) => new Promise((res) => {
|
|
24
|
+
const s = createServer();
|
|
25
|
+
s.once('error', () => res(false));
|
|
26
|
+
s.once('listening', () => s.close(() => res(true)));
|
|
27
|
+
s.listen(p, '127.0.0.1');
|
|
28
|
+
});
|
|
29
|
+
async function pickPort(start) {
|
|
30
|
+
for (let p = start; p < start + 30; p++) { if (await canBind(p)) return p; }
|
|
31
|
+
return start; // give up → the server's own EADDRINUSE guard will explain
|
|
32
|
+
}
|
|
16
33
|
// On Windows the `claude`/`ffmpeg` executables are .cmd/.ps1 shims that a bare
|
|
17
34
|
// spawnSync can't resolve (ENOENT) — run through the shell there so PATHEXT
|
|
18
35
|
// resolution kicks in. On macOS/Linux keep shell off (no injection surface).
|
|
@@ -29,6 +46,12 @@ if (claude.error || claude.status !== 0) {
|
|
|
29
46
|
}
|
|
30
47
|
say(`claude CLI: ${claude.stdout.trim()}`);
|
|
31
48
|
|
|
49
|
+
// 1b) codex CLI (optional alternate worker). Informational only — so Codex users
|
|
50
|
+
// see it's detected and supported, not just Claude Code. Galda can run workers on
|
|
51
|
+
// either; you pick per task (Claude Code / Codex) in the board's composer.
|
|
52
|
+
const codex = probe('codex', ['--version']);
|
|
53
|
+
if (!codex.error && codex.status === 0) say(`codex CLI: ${codex.stdout.trim()} (Codex)`);
|
|
54
|
+
|
|
32
55
|
// 2) Chrome (for verification proof) — recommended; warn if missing
|
|
33
56
|
const chromeCandidates = [
|
|
34
57
|
process.env.CHROME_PATH,
|
|
@@ -57,8 +80,18 @@ process.env.MANAGER_OPEN_BROWSER = process.env.MANAGER_OPEN_BROWSER ?? '1';
|
|
|
57
80
|
// and `npm start` from the repo stay env-driven and don't hit the live worker.
|
|
58
81
|
// The license public key is already baked in engine/lib.mjs (offline verify).
|
|
59
82
|
// Opt out with an empty value: `MANAGER_BILLING_API_URL= RELAY_URL= npx @galda/cli`.
|
|
60
|
-
|
|
83
|
+
// Use the OWNED custom domain (galda.app), never the Cloudflare workers.dev
|
|
84
|
+
// subdomain — that subdomain is named after an unrelated client (a2c-tech) and
|
|
85
|
+
// must never appear in Galda's product. galda.app is bound to the same Worker
|
|
86
|
+
// and serves the whole API (/connect, /checkout, /api/*). See [[no-a2c-tech]].
|
|
87
|
+
process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://galda.app';
|
|
61
88
|
process.env.RELAY_URL = process.env.RELAY_URL ?? 'wss://app.galda.app/agent';
|
|
89
|
+
// Choose a free port now and pin it for BOTH the server and the relay-client, so
|
|
90
|
+
// a busy 4400 (another manager) no longer stops onboarding — no MANAGER_PORT by hand.
|
|
91
|
+
const wantedPort = Number(process.env.MANAGER_PORT ?? 4400);
|
|
92
|
+
const port = await pickPort(wantedPort);
|
|
93
|
+
if (port !== wantedPort) say(`port ${wantedPort} is busy — using ${port} instead`);
|
|
94
|
+
process.env.MANAGER_PORT = String(port);
|
|
62
95
|
await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
|
|
63
96
|
|
|
64
97
|
// 5) fixed-URL relay (optional): if a relay is configured, connect this local
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// are swallowed so analytics can never break a task run or block the caller.
|
|
10
10
|
//
|
|
11
11
|
// Hook points (wiring lives in engine/relay, kept out of this module):
|
|
12
|
-
// signup →
|
|
12
|
+
// signup → engine, once per install when the MCP client (Claude
|
|
13
|
+
// Code / Codex) first connects = "setup completed"
|
|
14
|
+
// (maybeEmitSetupCompleted in server.mjs)
|
|
13
15
|
// task_created → engine, when a task/goal is accepted
|
|
14
16
|
// task_completed → engine, task.status = 'done'
|
|
15
17
|
// task_failed → engine, task.status = 'failed'
|
package/engine/lib.mjs
CHANGED
|
@@ -674,6 +674,20 @@ export function nextStreamScrollTop(pre, newScrollHeight, threshold = 100) {
|
|
|
674
674
|
: pre.scrollTop;
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
+
// Same pin decision for the inner live worker-activity log (`.tres.runlog`) of a
|
|
678
|
+
// running task, whose HTML is fully rebuilt on every SSE render. `pre` is the
|
|
679
|
+
// element's geometry captured BEFORE the swap, or null if that runlog did not
|
|
680
|
+
// exist last render (a task that just started). A brand-new log pins to the
|
|
681
|
+
// bottom; an existing one follows the bottom only when the user was already near
|
|
682
|
+
// it — otherwise it keeps the exact prior scrollTop so scrolling up to re-read
|
|
683
|
+
// earlier lines mid-run is never yanked back down.
|
|
684
|
+
export function nextRunlogScrollTop(pre, newScrollHeight, threshold = 100) {
|
|
685
|
+
if (!pre) return newScrollHeight;
|
|
686
|
+
return shouldAutoScroll(pre.scrollTop, pre.scrollHeight, pre.clientHeight, threshold)
|
|
687
|
+
? newScrollHeight
|
|
688
|
+
: pre.scrollTop;
|
|
689
|
+
}
|
|
690
|
+
|
|
677
691
|
// GET /api/state payload for tasks (task 49): the browser's live activity
|
|
678
692
|
// log (state.act) is otherwise only ever populated by streamed SSE 'act'
|
|
679
693
|
// events, so a page refresh mid-run would blank a running task's log until
|
|
@@ -2376,3 +2390,13 @@ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK
|
|
|
2376
2390
|
}
|
|
2377
2391
|
return { valid: true, reason: null, payload };
|
|
2378
2392
|
}
|
|
2393
|
+
|
|
2394
|
+
// Setup-completion analytics: the `signup` event fires exactly ONCE per install,
|
|
2395
|
+
// the first time the MCP client (Claude Code / Codex) connects to the Manager.
|
|
2396
|
+
// Pure decision so it can be unit-tested without a live server or filesystem —
|
|
2397
|
+
// the caller supplies whether the request is from the MCP client, whether this
|
|
2398
|
+
// process already saw it this session, and whether the persisted once-ever flag
|
|
2399
|
+
// already exists on disk.
|
|
2400
|
+
export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists }) {
|
|
2401
|
+
return Boolean(isMcpClient) && !alreadySeen && !flagExists;
|
|
2402
|
+
}
|
package/engine/mcp.mjs
CHANGED
|
@@ -24,7 +24,10 @@ const KEY = process.env.MANAGER_KEY
|
|
|
24
24
|
|
|
25
25
|
async function api(path, opts = {}) {
|
|
26
26
|
const sep = path.includes('?') ? '&' : '?';
|
|
27
|
-
|
|
27
|
+
// Tag every MCP→Manager call so the server can detect "connected to Claude
|
|
28
|
+
// Code / Codex" (the setup-completed signal) — see maybeEmitSetupCompleted.
|
|
29
|
+
const headers = { ...(opts.headers || {}), 'x-manager-client': 'mcp' };
|
|
30
|
+
const r = await fetch(`${BASE}${path}${sep}key=${KEY}`, { ...opts, headers });
|
|
28
31
|
if (!r.ok) throw new Error(`Manager server ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
|
29
32
|
return r.json();
|
|
30
33
|
}
|
package/engine/relay-client.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// RELAY_URL=wss://relay.example/agent RELAY_AGENT_TOKEN=… node engine/relay-client.mjs
|
|
10
10
|
|
|
11
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, existsSync, watch } from 'node:fs';
|
|
12
12
|
import { resolve, dirname, join } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,19 +19,40 @@ const DATA_DIR = process.env.MANAGER_HOME
|
|
|
19
19
|
const LOCAL = process.env.MANAGER_LOCAL ?? `http://localhost:${process.env.MANAGER_PORT ?? 4400}`;
|
|
20
20
|
const KEY = existsSync(join(DATA_DIR, 'secret.key')) ? readFileSync(join(DATA_DIR, 'secret.key'), 'utf8').trim() : '';
|
|
21
21
|
const RELAY = process.env.RELAY_URL ?? 'ws://localhost:5500/agent';
|
|
22
|
+
const DEV_EMAIL = process.env.RELAY_DEV_EMAIL ?? '';
|
|
23
|
+
const LICENSE_FILE = join(DATA_DIR, 'license.token');
|
|
22
24
|
// The relay (P3.1) authenticates the agent by its signed LICENSE TOKEN — only
|
|
23
25
|
// the billing Worker can mint one for a verified email, so the relay binds this
|
|
24
26
|
// socket to that email's id (no shared token, unspoofable). Falls back to an
|
|
25
27
|
// explicit RELAY_AGENT_TOKEN, and to a dev email for local testing.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// Read FRESH each connect (not once at load): on first run the agent starts with
|
|
29
|
+
// no token (bound to nobody); when the user signs in, license.token appears and
|
|
30
|
+
// the watcher below reconnects, so the relay re-binds this socket to the real
|
|
31
|
+
// identity and the user's hosted board comes alive — no CLI restart needed.
|
|
32
|
+
function readToken() {
|
|
33
|
+
const lic = existsSync(LICENSE_FILE) ? readFileSync(LICENSE_FILE, 'utf8').trim() : '';
|
|
34
|
+
return process.env.RELAY_AGENT_TOKEN ?? lic;
|
|
35
|
+
}
|
|
29
36
|
|
|
30
37
|
const live = new Map(); // reqId -> AbortController
|
|
38
|
+
let currentWs = null;
|
|
39
|
+
let reconnectTimer = null;
|
|
40
|
+
let lastToken = readToken();
|
|
41
|
+
|
|
42
|
+
function scheduleReconnect(retryMs) {
|
|
43
|
+
if (reconnectTimer) return; // exactly one pending reconnect — never stack timers
|
|
44
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(Math.min(retryMs * 2, 15000)); }, retryMs);
|
|
45
|
+
}
|
|
31
46
|
|
|
32
47
|
function connect(retryMs = 1000) {
|
|
33
|
-
|
|
48
|
+
// Single-socket invariant: never open a second socket while one is CONNECTING
|
|
49
|
+
// (0) or OPEN (1). Two live sockets with the same identity make the relay evict
|
|
50
|
+
// each other (server.mjs "replaced by a newer connection") → an infinite flap.
|
|
51
|
+
if (currentWs && (currentWs.readyState === 0 || currentWs.readyState === 1)) return;
|
|
52
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
53
|
+
const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(readToken())}`;
|
|
34
54
|
const ws = new WebSocket(`${RELAY}?${auth}`);
|
|
55
|
+
currentWs = ws;
|
|
35
56
|
ws.onopen = () => { retryMs = 1000; console.log(`[agent] connected to relay ${RELAY}`); };
|
|
36
57
|
ws.onmessage = async (ev) => {
|
|
37
58
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
|
@@ -73,10 +94,35 @@ function connect(retryMs = 1000) {
|
|
|
73
94
|
}
|
|
74
95
|
};
|
|
75
96
|
ws.onclose = () => {
|
|
97
|
+
if (ws !== currentWs) return; // a superseded socket (we already moved on) — don't reconnect
|
|
76
98
|
console.log(`[agent] relay connection lost — retrying in ${retryMs}ms`);
|
|
77
|
-
|
|
99
|
+
scheduleReconnect(retryMs);
|
|
78
100
|
};
|
|
79
101
|
ws.onerror = () => { /* onclose follows */ };
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
connect();
|
|
105
|
+
|
|
106
|
+
// Sign-in reconnect: when license.token appears/changes (the user just linked
|
|
107
|
+
// this device), drop the current socket so connect() re-dials with the new
|
|
108
|
+
// identity. Without this the first-run socket stays bound to nobody and the
|
|
109
|
+
// hosted board never comes alive until the CLI is restarted. Watch the DIR (the
|
|
110
|
+
// file may not exist yet on first run); best-effort (fs.watch unsupported → the
|
|
111
|
+
// onclose retry loop still re-reads the token on the next natural reconnect).
|
|
112
|
+
try {
|
|
113
|
+
watch(DATA_DIR, (_ev, f) => {
|
|
114
|
+
if (f && f !== 'license.token') return;
|
|
115
|
+
const t = readToken();
|
|
116
|
+
if (t && t !== lastToken) {
|
|
117
|
+
lastToken = t;
|
|
118
|
+
console.log('[agent] signed in — reconnecting to relay with your identity');
|
|
119
|
+
// Detach the old socket FIRST so its onclose is ignored (ws !== currentWs),
|
|
120
|
+
// then reconnect once, immediately, with a fresh backoff — no racing chains.
|
|
121
|
+
const old = currentWs;
|
|
122
|
+
currentWs = null;
|
|
123
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
124
|
+
try { old?.close(); } catch { /* already gone */ }
|
|
125
|
+
connect(1000);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
} catch { /* fs.watch unavailable on this platform */ }
|
package/engine/server.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { resolve, dirname, join, basename } from 'node:path';
|
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken } from './lib.mjs';
|
|
23
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted } from './lib.mjs';
|
|
24
24
|
import { openPR } from './pr.mjs';
|
|
25
25
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
26
26
|
|
|
@@ -41,6 +41,20 @@ const ACCESS_KEY = readFileSync(keyFile, 'utf8').trim();
|
|
|
41
41
|
// access key so the raw credential NEVER leaves the machine (the billing Worker
|
|
42
42
|
// hashes this again into its pseudonymous id). Not the access key itself.
|
|
43
43
|
const ANALYTICS_UID = createHash('sha256').update('galda-analytics:' + ACCESS_KEY).digest('hex').slice(0, 24);
|
|
44
|
+
// "Setup completed" = npx-installed AND connected to Claude Code / Codex. That
|
|
45
|
+
// connection is the MCP client (engine/mcp.mjs) first reaching this server, so
|
|
46
|
+
// we fire the `signup` event once, the first time an MCP-tagged request arrives,
|
|
47
|
+
// and persist a flag so it counts once per install ever (not once per restart).
|
|
48
|
+
const SETUP_FLAG = join(DATA_DIR, 'setup-completed.flag');
|
|
49
|
+
let _setupClientSeen = false;
|
|
50
|
+
function maybeEmitSetupCompleted(req) {
|
|
51
|
+
if (_setupClientSeen) return; // already decided this session — no repeat disk hit
|
|
52
|
+
if ((req.headers['x-manager-client'] || '') !== 'mcp') return;
|
|
53
|
+
_setupClientSeen = true;
|
|
54
|
+
if (!shouldEmitSetupCompleted({ isMcpClient: true, alreadySeen: false, flagExists: existsSync(SETUP_FLAG) })) return;
|
|
55
|
+
try { writeFileSync(SETUP_FLAG, String(Date.now())); } catch { /* best effort — still emit */ }
|
|
56
|
+
emitEvent('signup', ANALYTICS_UID, { source: 'mcp-connect' });
|
|
57
|
+
}
|
|
44
58
|
// Per-worker run cap. Env-configurable so it can be tuned operationally and the
|
|
45
59
|
// timeout→'interrupted' path is testable (a test sets a short value + a slow
|
|
46
60
|
// fake worker instead of waiting 10 real minutes).
|
|
@@ -915,6 +929,7 @@ async function planGoal(goal) {
|
|
|
915
929
|
const project = projects.find((p) => p.id === goal.projectId);
|
|
916
930
|
const prompt = [
|
|
917
931
|
'あなたはユーザーの依頼を受けるプロダクトマネージャ。以下のユーザー依頼を、ユーザーから見た「成果物(トピック)」の単位で整理する。エンジニアリングの工程には分解しない。',
|
|
932
|
+
'【言語】ユーザーが読む文字列(各タスクの title、確認の question / options)は、末尾の「依頼:」と同じ言語で書く。英語の依頼には英語で、日本語の依頼には日本語で返す(例: "test" → 英語、"テスト" → 日本語)。detail(worker への指示)は言語自由。',
|
|
918
933
|
'- 既定は 1トピック=1タスク。1つのまとまった依頼を実装工程に割らない(「PRDを作る」「パーサを追加」「テストを書く」「リファクタ」等を別タスクにしない)。それらは1人のworkerが内部で全部やる(実装→テスト→proof/検証まで一気通貫)。',
|
|
919
934
|
'- 複数タスクに分けるのは、依頼に「明確に別々の成果物・トピック」が含まれる時「だけ」(例:「音声入力を追加して、あとビジュアル作成も」→ 音声入力 / ビジュアル作成 の2つ)。工程での分割は絶対にしない。',
|
|
920
935
|
'- 被っている・言い換えているだけの依頼は、自分で理解して1つにまとめる。',
|
|
@@ -926,7 +941,7 @@ async function planGoal(goal) {
|
|
|
926
941
|
// Once the user has answered a clarification, NEVER ask again — re-asking looped
|
|
927
942
|
// the answered question back onto the board. Override the rule above for re-plans.
|
|
928
943
|
goal.clarified ? '【最重要】ユーザーは既にこの依頼への確認質問に回答済み(依頼文末尾に [確認: … → …] として反映済み)。これ以上、確認質問(question)を返してはならない。回答を前提に、必ずタスクへ分解して返すこと。' : '',
|
|
929
|
-
'- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"
|
|
944
|
+
'- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(依頼と同じ言語で短く)","detail":"workerへの完全な指示","passCondition":"検証条件|null"}]} / 確認が要る時だけ: {"question":"...(依頼と同じ言語)","options":["...","..."]}',
|
|
930
945
|
'',
|
|
931
946
|
goal.priorFailureMemory?.length ? [
|
|
932
947
|
'過去の失敗メモリ(同じ失敗を避けること):',
|
|
@@ -1298,6 +1313,9 @@ function testOutputExcerpt(text) {
|
|
|
1298
1313
|
}
|
|
1299
1314
|
|
|
1300
1315
|
function workerPrompt(task, goal, lastFailure) {
|
|
1316
|
+
// Report back to the human in the SAME language they wrote the goal in (Masa
|
|
1317
|
+
// 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
|
|
1318
|
+
const replyLang = /[-ヿ㐀-鿿]/.test(goal?.text || task?.title || '') ? '日本語' : 'English';
|
|
1301
1319
|
// Optional skill the user attached to this goal. Empirically (scratch worker,
|
|
1302
1320
|
// haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
|
|
1303
1321
|
// instruction as the FIRST line; a bare "/name" first line did not consistently
|
|
@@ -1329,7 +1347,7 @@ function workerPrompt(task, goal, lastFailure) {
|
|
|
1329
1347
|
'- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
|
|
1330
1348
|
'- 検証しすぎない: 自分の変更が動くと確認できたら、それ以上ログを漁ったり再検証を重ねたりせず、すぐに報告して終える(時間と token の無駄を避ける)。',
|
|
1331
1349
|
'- Do not commit (git is handled by the Manager).',
|
|
1332
|
-
|
|
1350
|
+
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
1333
1351
|
].filter(Boolean).join('\n');
|
|
1334
1352
|
}
|
|
1335
1353
|
|
|
@@ -2357,6 +2375,8 @@ const server = createServer(async (req, res) => {
|
|
|
2357
2375
|
res.setHeader('set-cookie', `mkey=${ACCESS_KEY}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
2358
2376
|
}
|
|
2359
2377
|
}
|
|
2378
|
+
// Once past auth, the first MCP-client request marks setup as completed.
|
|
2379
|
+
maybeEmitSetupCompleted(req);
|
|
2360
2380
|
|
|
2361
2381
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
2362
2382
|
// no-cache: browsers were keeping a STALE index.html (no cache header before),
|
|
@@ -3337,7 +3357,17 @@ server.on('error', (e) => {
|
|
|
3337
3357
|
server.listen(PORT, '127.0.0.1', () => {
|
|
3338
3358
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
|
3339
3359
|
if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
|
|
3340
|
-
|
|
3360
|
+
// First run in the hosted flow (no license yet, billing worker configured):
|
|
3361
|
+
// open the one-click Google device-link straight away instead of the local
|
|
3362
|
+
// ?key board, so a non-engineer never sees a localhost URL or an access key —
|
|
3363
|
+
// they sign in once and their hosted board comes alive (relay-client rebinds
|
|
3364
|
+
// via its license.token watcher). Otherwise open the local board as before.
|
|
3365
|
+
let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
|
|
3366
|
+
if (BILLING_API_URL && !existsSync(licenseFile)) {
|
|
3367
|
+
openUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
|
|
3368
|
+
console.log('[manager] first run: opening one-click sign-in (no local key needed) → ' + openUrl);
|
|
3369
|
+
}
|
|
3370
|
+
spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
|
|
3341
3371
|
}
|
|
3342
3372
|
console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
|
|
3343
3373
|
syncAllExternal().catch(() => {});
|
package/package.json
CHANGED