@galda/cli 0.10.7 → 0.10.9
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/bin/manager-for-ai.mjs +7 -0
- package/engine/lib.mjs +52 -0
- package/engine/server.mjs +128 -18
- package/package.json +1 -1
package/bin/manager-for-ai.mjs
CHANGED
|
@@ -86,6 +86,13 @@ process.env.MANAGER_OPEN_BROWSER = process.env.MANAGER_OPEN_BROWSER ?? '1';
|
|
|
86
86
|
// and serves the whole API (/connect, /checkout, /api/*). See [[no-a2c-tech]].
|
|
87
87
|
process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://galda.app';
|
|
88
88
|
process.env.RELAY_URL = process.env.RELAY_URL ?? 'wss://app.galda.app/agent';
|
|
89
|
+
// The named app the user actually works in (over the relay). The CLI opens THIS,
|
|
90
|
+
// not the localhost board, in the hosted flow. Override for a self-hosted app.
|
|
91
|
+
process.env.MANAGER_APP_URL = process.env.MANAGER_APP_URL ?? 'https://app.galda.app';
|
|
92
|
+
// `npx @galda/cli --signin` forces the Google sign-in even if a license already
|
|
93
|
+
// exists — the way to switch to a different account (a stale token otherwise
|
|
94
|
+
// binds you to the previous account).
|
|
95
|
+
if (process.argv.includes('--signin') || process.argv.includes('--login')) process.env.MANAGER_FORCE_SIGNIN = '1';
|
|
89
96
|
// Choose a free port now and pin it for BOTH the server and the relay-client, so
|
|
90
97
|
// a busy 4400 (another manager) no longer stops onboarding — no MANAGER_PORT by hand.
|
|
91
98
|
const wantedPort = Number(process.env.MANAGER_PORT ?? 4400);
|
package/engine/lib.mjs
CHANGED
|
@@ -2400,3 +2400,55 @@ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK
|
|
|
2400
2400
|
export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists }) {
|
|
2401
2401
|
return Boolean(isMcpClient) && !alreadySeen && !flagExists;
|
|
2402
2402
|
}
|
|
2403
|
+
|
|
2404
|
+
// Onboarding funnel: a goal can only produce real changes if it runs inside a
|
|
2405
|
+
// real code repo. When the agent is started ad-hoc (e.g. `node bin/...` in the
|
|
2406
|
+
// user's HOME), the seeded default project points at a non-repo dir → the
|
|
2407
|
+
// worker no-ops (nothing to edit) yet the goal is marked "done" — the exact
|
|
2408
|
+
// blocker for "実装が始まる". This decides, BEFORE running, whether we must ask
|
|
2409
|
+
// the user which folder to work in. `isRepo` is passed in (the git probe is
|
|
2410
|
+
// I/O, done by the caller) so this stays pure/testable. Home is never a valid
|
|
2411
|
+
// work root — even a git-init'd home dir is "no project chosen yet".
|
|
2412
|
+
export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
|
|
2413
|
+
if (!dir) return true;
|
|
2414
|
+
const norm = (p) => String(p).replace(/\/+$/, '');
|
|
2415
|
+
if (homeDir && norm(dir) === norm(homeDir)) return true;
|
|
2416
|
+
return !isRepo;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// A short, ≤60-char display label for a repo dir, shown home-relative (~/…) so
|
|
2420
|
+
// the folder-choice options read cleanly on the board card.
|
|
2421
|
+
export function folderLabel(dir, homeDir = '') {
|
|
2422
|
+
if (!dir) return '';
|
|
2423
|
+
let d = String(dir);
|
|
2424
|
+
const h = String(homeDir || '').replace(/\/+$/, '');
|
|
2425
|
+
if (h && (d === h || d.startsWith(h + '/'))) d = '~' + d.slice(h.length);
|
|
2426
|
+
return d.length <= 60 ? d : '…' + d.slice(-59);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Build the "which folder should I work in?" clarifying question from detected
|
|
2430
|
+
// repos. Reuses the existing needsInput card ({text, options:string[]}), and
|
|
2431
|
+
// adds kind:'folder' + folders[] + lang so the /answer handler rebinds the
|
|
2432
|
+
// goal's project to the chosen repo (instead of appending to goal.text like a
|
|
2433
|
+
// task clarification). `folders` = [{label, dir}]. Options are capped/shaped to
|
|
2434
|
+
// what the board expects (≤8 entries, each ≤60 chars) with a trailing
|
|
2435
|
+
// "somewhere else" escape for a typed path.
|
|
2436
|
+
export function buildFolderQuestion(folders = [], { lang = 'ja' } = {}) {
|
|
2437
|
+
const capped = folders.slice(0, 7).map((f) => ({ label: String(f.label || f.dir || ''), dir: f.dir }));
|
|
2438
|
+
const other = lang === 'en' ? 'Somewhere else…' : '別の場所を指定…';
|
|
2439
|
+
const text = lang === 'en' ? 'Which folder should I work in?' : 'どのフォルダで実装しますか?';
|
|
2440
|
+
return { text, options: [...capped.map((f) => f.label), other], folders: capped, kind: 'folder', lang, other };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Resolve a folder-choice answer (a picked option label, or a typed path) back
|
|
2444
|
+
// to an absolute dir. Returns { dir } or null (→ the "somewhere else" sentinel /
|
|
2445
|
+
// an unrecognized answer, which the caller re-asks on). Match by label first,
|
|
2446
|
+
// then exact dir, then treat an absolute-looking answer as a literal path.
|
|
2447
|
+
export function resolveFolderAnswer(answer, folders = []) {
|
|
2448
|
+
const a = String(answer ?? '').trim();
|
|
2449
|
+
if (!a) return null;
|
|
2450
|
+
const hit = folders.find((f) => f.label === a || f.dir === a);
|
|
2451
|
+
if (hit) return { dir: hit.dir };
|
|
2452
|
+
if (a.startsWith('/') || a.startsWith('~')) return { dir: a };
|
|
2453
|
+
return null;
|
|
2454
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
16
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
17
17
|
import { emitEvent } from './analytics-client.mjs';
|
|
18
18
|
import { randomBytes, createHash } from 'node:crypto';
|
|
19
19
|
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 { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer } from './lib.mjs';
|
|
23
24
|
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
25
|
import { openPR } from './pr.mjs';
|
|
25
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
@@ -53,7 +54,7 @@ function maybeEmitSetupCompleted(req) {
|
|
|
53
54
|
_setupClientSeen = true;
|
|
54
55
|
if (!shouldEmitSetupCompleted({ isMcpClient: true, alreadySeen: false, flagExists: existsSync(SETUP_FLAG) })) return;
|
|
55
56
|
try { writeFileSync(SETUP_FLAG, String(Date.now())); } catch { /* best effort — still emit */ }
|
|
56
|
-
emitEvent('signup',
|
|
57
|
+
emitEvent('signup', analyticsUid(), { source: 'mcp-connect' });
|
|
57
58
|
}
|
|
58
59
|
// Per-worker run cap. Env-configurable so it can be tuned operationally and the
|
|
59
60
|
// timeout→'interrupted' path is testable (a test sets a short value + a slow
|
|
@@ -151,6 +152,15 @@ const MANAGER_OWNER_EMAIL = process.env.MANAGER_OWNER_EMAIL ?? '';
|
|
|
151
152
|
// still works standalone (e.g. for Collaborators / dev) with no billing-api
|
|
152
153
|
// configured at all.
|
|
153
154
|
const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
|
|
155
|
+
// The user's real UI in the hosted flow is the NAMED app (app.galda.app),
|
|
156
|
+
// reached over the relay — never the localhost board (Masa 2026-07-15:
|
|
157
|
+
// 「npm→ターミナル→app.galda.appに戻る / localhostでなくアプリ名で開くべき」).
|
|
158
|
+
// The localhost board stays the entry only for local/dev (no billing) runs.
|
|
159
|
+
const APP_URL = (process.env.MANAGER_APP_URL ?? '').replace(/\/$/, '');
|
|
160
|
+
// Force the sign-in flow even when a license already exists — so a user can
|
|
161
|
+
// switch to a different Google account (a stale token from a previous account
|
|
162
|
+
// otherwise silently binds the relay to the wrong account). Set by `--signin`.
|
|
163
|
+
const FORCE_SIGNIN = process.env.MANAGER_FORCE_SIGNIN === '1';
|
|
154
164
|
const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
|
|
155
165
|
|
|
156
166
|
// License token (local-first identity, docs/BILLING-LAUNCH-PLAN.md): a signed
|
|
@@ -165,6 +175,14 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
|
|
|
165
175
|
const licenseFile = join(DATA_DIR, 'license.token');
|
|
166
176
|
let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
|
|
167
177
|
|
|
178
|
+
// Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
|
|
179
|
+
// (the billing Worker hashes this email → the same account_id the accounts table
|
|
180
|
+
// carries, so per-user app activity joins WITHOUT a cookie). Not signed in →
|
|
181
|
+
// the anonymous per-install id. Raw email never lands in usage_events (the
|
|
182
|
+
// Worker pseudonymizes it server-side).
|
|
183
|
+
function analyticsUid() { return licenseState.email || ANALYTICS_UID; }
|
|
184
|
+
let _freeExhaustedEmitted = false;
|
|
185
|
+
|
|
168
186
|
// One-time nonces for the "Sign in with Google" loopback (GET /api/signin-url →
|
|
169
187
|
// billing /connect → Google → billing → GET /oauth/callback). The engine issues
|
|
170
188
|
// the nonce, round-trips it through the flow, and requires it back — so a random
|
|
@@ -215,12 +233,17 @@ function signinResultPage(outcome) {
|
|
|
215
233
|
const body = outcome.ok
|
|
216
234
|
? `<h1>Signed in</h1><p>You're signed in as <b>${esc(outcome.email)}</b>. This tab will close automatically.</p>`
|
|
217
235
|
: `<h1>Sign-in didn't complete</h1><p>${esc(outcome.error)}</p><p class="muted">Close this tab and click “Sign in with Google” again in the app.</p>`;
|
|
236
|
+
// When the sign-in was opened FROM the app tab (window.opener present), tell
|
|
237
|
+
// it and close. When it was opened by the CLI at startup (no opener — `open`
|
|
238
|
+
// can't script-close it), don't strand the user on a localhost page: send them
|
|
239
|
+
// to the named app so the flow is npm → terminal → app.galda.app.
|
|
240
|
+
const goApp = outcome.ok && APP_URL ? JSON.stringify(APP_URL) : 'null';
|
|
218
241
|
return `<!doctype html><meta charset="utf-8"><title>Galda</title>` +
|
|
219
242
|
`<body style="font:15px/1.6 -apple-system,system-ui,sans-serif;max-width:32rem;margin:12vh auto;padding:0 1.5rem;color:#111">` +
|
|
220
243
|
`<style>h1{font-size:1.35rem;margin:0 0 .5rem}.muted{color:#888;font-size:.9rem}b{font-weight:600}</style>` +
|
|
221
244
|
body +
|
|
222
|
-
`<script>try{if(window.opener)window.opener.postMessage(${payload},'*')}catch(e){}` +
|
|
223
|
-
(outcome.ok ? `setTimeout(function(){try{window.close()}catch(e){}},
|
|
245
|
+
`<script>var opened=false;try{if(window.opener){window.opener.postMessage(${payload},'*');opened=true;}}catch(e){}` +
|
|
246
|
+
(outcome.ok ? `var app=${goApp};setTimeout(function(){if(opened){try{window.close()}catch(e){}}else if(app){location.replace(app)}else{try{window.close()}catch(e){}}},900);` : '') +
|
|
224
247
|
`</script></body>`;
|
|
225
248
|
}
|
|
226
249
|
|
|
@@ -347,7 +370,7 @@ async function requireEntitlementGate(identity) {
|
|
|
347
370
|
const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
|
|
348
371
|
if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
|
|
349
372
|
const billingEmail = licenseState.email;
|
|
350
|
-
if (!billingEmail)
|
|
373
|
+
if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
|
|
351
374
|
const cachedState = resolveCachedEntitlement({ cached: entitlementCache.get(billingEmail) ?? null });
|
|
352
375
|
let isPaying = cachedState.isPaying;
|
|
353
376
|
if (cachedState.needsRefresh) {
|
|
@@ -357,7 +380,17 @@ async function requireEntitlementGate(identity) {
|
|
|
357
380
|
entitlementCache.set(billingEmail, { isPaying: fresh, checkedAt: Date.now() });
|
|
358
381
|
}
|
|
359
382
|
}
|
|
360
|
-
|
|
383
|
+
const result = resolveEntitlement({ isOwner: false, isPaying, usage });
|
|
384
|
+
noteFreeExhausted(result);
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
// Emit one free_exhausted funnel event the first time a non-paying install is
|
|
388
|
+
// blocked by the free-tier cap (the funnel dedupes by distinct account/install,
|
|
389
|
+
// so once-per-session is enough).
|
|
390
|
+
function noteFreeExhausted(result) {
|
|
391
|
+
if (_freeExhaustedEmitted || !result?.blocked) return;
|
|
392
|
+
_freeExhaustedEmitted = true;
|
|
393
|
+
emitEvent('free_exhausted', analyticsUid());
|
|
361
394
|
}
|
|
362
395
|
// The acting identity for a request: 'owner' for key-auth or
|
|
363
396
|
// MANAGER_OWNER_EMAIL, else the verified Google email itself. Stamped onto
|
|
@@ -428,6 +461,10 @@ async function authFromCfAccess(req) {
|
|
|
428
461
|
}
|
|
429
462
|
}
|
|
430
463
|
|
|
464
|
+
// Where to look for the user's repos (folder-choice options) and which dir is
|
|
465
|
+
// "not a real project" (never a valid work root). Defaults to the OS home;
|
|
466
|
+
// overridable for tests / operators whose repos live elsewhere.
|
|
467
|
+
const HOME = process.env.MANAGER_SCAN_HOME || homedir();
|
|
431
468
|
const projectsFile = join(DATA_DIR, 'projects.json');
|
|
432
469
|
if (!existsSync(projectsFile)) {
|
|
433
470
|
const cwd = process.cwd();
|
|
@@ -573,12 +610,12 @@ function emitTaskAnalytics(t) {
|
|
|
573
610
|
if (!t || t.id == null) return;
|
|
574
611
|
if (!_analyticsEmitted.has(`created:${t.id}`)) {
|
|
575
612
|
_analyticsEmitted.add(`created:${t.id}`);
|
|
576
|
-
emitEvent('task_created',
|
|
613
|
+
emitEvent('task_created', analyticsUid(), { source: 'engine' });
|
|
577
614
|
}
|
|
578
615
|
const ev = _TERMINAL_EVENT[t.status];
|
|
579
616
|
if (ev && !_analyticsEmitted.has(`${t.status}:${t.id}`)) {
|
|
580
617
|
_analyticsEmitted.add(`${t.status}:${t.id}`);
|
|
581
|
-
emitEvent(ev,
|
|
618
|
+
emitEvent(ev, analyticsUid(), { status: t.status, source: 'engine' });
|
|
582
619
|
postHistory(t);
|
|
583
620
|
}
|
|
584
621
|
}
|
|
@@ -784,6 +821,30 @@ function repoDefaultBranch(dir) {
|
|
|
784
821
|
try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
|
|
785
822
|
catch { return 'main'; }
|
|
786
823
|
}
|
|
824
|
+
// Non-throwing "is this a real git work tree?" probe. The onboarding guard uses
|
|
825
|
+
// it to refuse running a goal against a non-repo (which would no-op) and to
|
|
826
|
+
// validate a folder the user picks.
|
|
827
|
+
function isGitRepo(dir) {
|
|
828
|
+
try { return !!dir && existsSync(dir) && gitSync(dir, ['rev-parse', '--is-inside-work-tree']) === 'true'; }
|
|
829
|
+
catch { return false; }
|
|
830
|
+
}
|
|
831
|
+
// Shallow-scan the user's HOME for candidate repos to offer as folder choices
|
|
832
|
+
// (depth-1: direct children of HOME with a `.git` — matches how devs keep repos
|
|
833
|
+
// side by side, and stays fast/safe vs. a deep `find`). The typed-path escape
|
|
834
|
+
// in the folder question covers anything deeper.
|
|
835
|
+
function detectRepos(homeDir) {
|
|
836
|
+
const out = [];
|
|
837
|
+
try {
|
|
838
|
+
for (const name of readdirSync(homeDir)) {
|
|
839
|
+
if (name.startsWith('.')) continue;
|
|
840
|
+
const dir = join(homeDir, name);
|
|
841
|
+
try {
|
|
842
|
+
if (statSync(dir).isDirectory() && existsSync(join(dir, '.git'))) out.push({ dir, label: folderLabel(dir, homeDir) });
|
|
843
|
+
} catch { /* unreadable entry — skip */ }
|
|
844
|
+
}
|
|
845
|
+
} catch { /* unreadable home — return whatever we have */ }
|
|
846
|
+
return out.slice(0, 12);
|
|
847
|
+
}
|
|
787
848
|
// Each goal works in its OWN git worktree, branched fresh from origin/<default>,
|
|
788
849
|
// under <repo>/.manager-wt/goal-<id>. So: (1) the user's real working dir is
|
|
789
850
|
// never touched; (2) each goal's diff contains only that goal's changes on top
|
|
@@ -2661,11 +2722,25 @@ const server = createServer(async (req, res) => {
|
|
|
2661
2722
|
// no PR) — the reviewer just Approves (files it) or Dismisses (drops
|
|
2662
2723
|
// it). This is how "needs my judgment" reaches the phone without kicking
|
|
2663
2724
|
// off an implementation run.
|
|
2725
|
+
// Onboarding funnel (docs/HANDOFF-ONBOARDING-conversational.md): a real
|
|
2726
|
+
// implementation goal can only produce changes inside a real code repo.
|
|
2727
|
+
// If the active project points at a non-repo (e.g. the HOME dir when the
|
|
2728
|
+
// agent was started ad-hoc), running would no-op — nothing to edit — yet
|
|
2729
|
+
// report "done". Instead of that fake "fixed", ask which folder first,
|
|
2730
|
+
// reusing the needsInput clarifying card; the answer (POST /answer)
|
|
2731
|
+
// rebinds this goal to the chosen repo, then planning proceeds. Skipped
|
|
2732
|
+
// for pending/review items (they never spawn a worker).
|
|
2733
|
+
const runnable = !pending && !review;
|
|
2734
|
+
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
2735
|
+
const folderQuestion = needsFolder
|
|
2736
|
+
? buildFolderQuestion(detectRepos(HOME), { lang: /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en' })
|
|
2737
|
+
: undefined;
|
|
2664
2738
|
const goalAgent = workerAgent(agent);
|
|
2665
2739
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
2666
2740
|
const goal = {
|
|
2667
2741
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
2668
|
-
status: review ? 'review' : pending ? 'pending' : 'planning',
|
|
2742
|
+
status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
2743
|
+
question: folderQuestion,
|
|
2669
2744
|
reviewOnly: review ? true : undefined,
|
|
2670
2745
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
2671
2746
|
wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
@@ -2695,7 +2770,7 @@ const server = createServer(async (req, res) => {
|
|
|
2695
2770
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
2696
2771
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
2697
2772
|
// are terminal-until-human too, so they also skip planGoal.
|
|
2698
|
-
if (
|
|
2773
|
+
if (runnable && !needsFolder) {
|
|
2699
2774
|
planGoal(goal).catch((e) => {
|
|
2700
2775
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
2701
2776
|
});
|
|
@@ -2994,6 +3069,34 @@ const server = createServer(async (req, res) => {
|
|
|
2994
3069
|
let answer = '';
|
|
2995
3070
|
try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
|
|
2996
3071
|
if (!answer) return json(res, 400, { error: 'answer required' });
|
|
3072
|
+
// Folder-choice answer (onboarding funnel): rebind the goal to the chosen
|
|
3073
|
+
// repo rather than appending to goal.text — a folder isn't a task
|
|
3074
|
+
// clarification. Validate it's a real repo (and not HOME); if not, re-ask
|
|
3075
|
+
// with a fresh detected list so we never fall through to a no-op run.
|
|
3076
|
+
if (goal.question?.kind === 'folder') {
|
|
3077
|
+
const resolved = resolveFolderAnswer(answer, goal.question.folders || []);
|
|
3078
|
+
let dir = resolved?.dir || answer;
|
|
3079
|
+
if (dir.startsWith('~')) dir = join(HOME, dir.slice(1).replace(/^\/+/, ''));
|
|
3080
|
+
if (dir !== HOME && isGitRepo(dir)) {
|
|
3081
|
+
const existing = projects.find((p) => p.dir === dir);
|
|
3082
|
+
let proj = existing;
|
|
3083
|
+
if (!proj) {
|
|
3084
|
+
proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
|
|
3085
|
+
projects.push(proj); saveProjects();
|
|
3086
|
+
}
|
|
3087
|
+
goal.projectId = proj.id;
|
|
3088
|
+
goal.question = null;
|
|
3089
|
+
goal.clarified = true;
|
|
3090
|
+
goal.status = 'planning';
|
|
3091
|
+
saveGoal(goal);
|
|
3092
|
+
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3093
|
+
return json(res, 200, goal);
|
|
3094
|
+
}
|
|
3095
|
+
// not a usable repo → keep asking (don't run a no-op)
|
|
3096
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang: goal.question.lang || 'ja' });
|
|
3097
|
+
saveGoal(goal);
|
|
3098
|
+
return json(res, 200, goal);
|
|
3099
|
+
}
|
|
2997
3100
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
2998
3101
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
2999
3102
|
goal.question = null;
|
|
@@ -3357,15 +3460,22 @@ server.on('error', (e) => {
|
|
|
3357
3460
|
server.listen(PORT, '127.0.0.1', () => {
|
|
3358
3461
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
|
3359
3462
|
if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
//
|
|
3363
|
-
//
|
|
3364
|
-
//
|
|
3463
|
+
// Hosted flow (billing worker + named app configured): the user lives in
|
|
3464
|
+
// app.galda.app, driven over the relay — they must NEVER be dropped on a
|
|
3465
|
+
// localhost board or an access key (Masa 2026-07-15: 「npm→ターミナル→
|
|
3466
|
+
// app.galda.appに戻る」). Not signed in (or switching accounts via --signin)
|
|
3467
|
+
// → one-click Google sign-in; already signed in → straight to the app (the
|
|
3468
|
+
// board comes alive as relay-client rebinds via its license.token watcher).
|
|
3469
|
+
// The localhost ?key board stays the entry ONLY for local/dev (no billing).
|
|
3365
3470
|
let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
|
|
3366
|
-
if (BILLING_API_URL &&
|
|
3367
|
-
|
|
3368
|
-
|
|
3471
|
+
if (BILLING_API_URL && APP_URL) {
|
|
3472
|
+
const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
|
|
3473
|
+
openUrl = signedIn
|
|
3474
|
+
? APP_URL
|
|
3475
|
+
: `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
|
|
3476
|
+
console.log(signedIn
|
|
3477
|
+
? `[manager] opening your app → ${APP_URL}`
|
|
3478
|
+
: `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
|
|
3369
3479
|
}
|
|
3370
3480
|
spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
|
|
3371
3481
|
}
|
package/package.json
CHANGED