@galda/cli 0.10.114 → 0.10.116

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/engine/lib.mjs CHANGED
@@ -897,7 +897,14 @@ export function parseStreamEvents(line) {
897
897
  const arg = i.file_path ?? i.path ?? i.pattern ?? i.command ?? i.url ?? i.query ?? '';
898
898
  out.push({ kind: 'activity', text: `${p.name} ${String(arg)}`.trim().slice(0, 140) });
899
899
  } else if (p.type === 'text' && p.text?.trim()) {
900
- out.push({ kind: 'activity', text: `✎ ${p.text.trim().replace(/\s+/g, ' ').slice(0, 110)}` });
900
+ // The worker talking, as opposed to the worker working. It was flattened
901
+ // into the same ✎-prefixed, 110-char activity line as every tool call,
902
+ // which is why a person who sent a request sat watching a tool log with
903
+ // nothing in it that reads as an answer (Masa's friend, 2026-07-31: three
904
+ // minutes and "no reply"). Kept whole and kept separate here; the caller
905
+ // still writes the short activity line, and can also show the first one as
906
+ // what it is — the agent's first words back.
907
+ out.push({ kind: 'say', text: p.text.trim() });
901
908
  }
902
909
  }
903
910
  return out;
@@ -4858,20 +4865,14 @@ export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists
4858
4865
  return Boolean(isMcpClient) && !alreadySeen && !flagExists;
4859
4866
  }
4860
4867
 
4861
- // Onboarding funnel: a goal can only produce real changes if it runs inside a
4862
- // real code repo. When the agent is started ad-hoc (e.g. `node bin/...` in the
4863
- // user's HOME), the seeded default project points at a non-repo dir the
4864
- // worker no-ops (nothing to edit) yet the goal is marked "done" the exact
4865
- // blocker for "実装が始まる". This decides, BEFORE running, whether we must ask
4866
- // the user which folder to work in. `isRepo` is passed in (the git probe is
4867
- // I/O, done by the caller) so this stays pure/testable. Home is never a valid
4868
- // work root even a git-init'd home dir is "no project chosen yet".
4869
- export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
4870
- if (!dir) return true;
4871
- const norm = (p) => String(p).replace(/\/+$/, '');
4872
- if (homeDir && norm(dir) === norm(homeDir)) return true;
4873
- return !isRepo;
4874
- }
4868
+ // `needsProjectFolder` lived here until 2026-07-31. It decided, before running,
4869
+ // that a folder was not good enough to work in (HOME, or not a git repo) and
4870
+ // that the person had to answer a question first. Masa removed the question:
4871
+ // `claude -p` in a plain folder simply works measuredso Galda asking is a
4872
+ // toll gate of our own invention, and the folder is now whatever the CLI was
4873
+ // started in, changeable on the composer chip by whoever cares. The builders
4874
+ // below stay: an install that was mid-question when it updated must still be
4875
+ // able to answer the one it already has.
4875
4876
 
4876
4877
  // The repo's own name: the last segment of its path. The composer chip prints this and
4877
4878
  // the folder menu prints it again on every row, so it lives here as one function — two
@@ -5354,6 +5355,67 @@ export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 150
5354
5355
  // for this" signal is a concrete MANAGER_PORT: the launcher (bin/manager-for-ai
5355
5356
  // .mjs) resolves a real port BEFORE spawn, while every ephemeral spawn uses
5356
5357
  // MANAGER_PORT=0 (OS-assigned). So port 0 is never a browser-opening boot.
5358
+ // What the terminal says when Galda starts.
5359
+ //
5360
+ // It used to say one log line among several — `[manager] Manager for AI →
5361
+ // http://localhost:4400/?key=…` — and then open a tab in the background, in
5362
+ // whatever browser the machine calls default. Masa's friend never saw that tab
5363
+ // (2026-07-31): it opened behind their windows in a browser they do not use, and
5364
+ // nothing on screen told them where their own copy of Galda was. The address is
5365
+ // the one thing a person needs, so it gets its own line and an instruction, and
5366
+ // the auto-opened tab is described as what it is — a convenience that may have
5367
+ // missed.
5368
+ //
5369
+ // Three destinations, and the wording differs because the links differ:
5370
+ // local — http://localhost:PORT/?key=… safe to paste anywhere on this Mac,
5371
+ // in any browser, as often as you like.
5372
+ // app — the hosted app; already signed in.
5373
+ // signin — a ONE-TIME sign-in link. Which browser it is opened in decides
5374
+ // which browser ends up signed in, so that choice is stated before
5375
+ // it is made rather than explained afterwards in a 400 page.
5376
+ // WHERE this install's Galda actually is. Decided from how it was installed, not
5377
+ // from whether a browser could be opened: `npx @galda/cli` configures the billing
5378
+ // + app URLs and those people live in the hosted app, while the localhost ?key
5379
+ // board is the entry only for local/dev. Reading this off the auto-open branch
5380
+ // (as the first version of the banner did) told a hosted user on Linux — or
5381
+ // anyone with MANAGER_OPEN_BROWSER=0 — to open a localhost board with an access
5382
+ // key, which the hosted flow has always refused to do.
5383
+ export function bootDestination({ hosted, signedIn, localUrl, appUrl, signinUrl } = {}) {
5384
+ if (!hosted) return { url: String(localUrl ?? ''), mode: 'local' };
5385
+ return signedIn
5386
+ ? { url: String(appUrl ?? ''), mode: 'app' }
5387
+ : { url: String(signinUrl ?? ''), mode: 'signin' };
5388
+ }
5389
+
5390
+ export function bootBanner({ url, mode = 'local', opened = false } = {}) {
5391
+ const address = String(url ?? '');
5392
+ if (!address) return [];
5393
+ const head = {
5394
+ local: 'Galda is running. Open this in your browser:',
5395
+ app: 'Galda is running. Open your board:',
5396
+ signin: 'Galda is running. Sign in to continue — open this in the browser you want to use Galda in:',
5397
+ }[mode] ?? 'Galda is running. Open this in your browser:';
5398
+ const foot = {
5399
+ local: 'Paste it into any browser on this Mac — the key is part of the address.',
5400
+ app: 'Paste it into any browser you are signed in to.',
5401
+ signin: 'This link works once. Whichever browser opens it is the one that ends up signed in.',
5402
+ }[mode];
5403
+ return [
5404
+ '',
5405
+ ` ${head}`,
5406
+ '',
5407
+ ` ${address}`,
5408
+ '',
5409
+ ` ${foot}`,
5410
+ // Said even when we opened a tab — especially then, for a sign-in link:
5411
+ // the tab we opened is what chose the browser, and the person may not have
5412
+ // seen it happen.
5413
+ ...(opened ? [' (A tab may already have opened in your default browser. If you did not see it,'
5414
+ + ' or you use a different browser, use the address above.)'] : []),
5415
+ '',
5416
+ ];
5417
+ }
5418
+
5357
5419
  export function shouldOpenBrowserOnBoot({ openBrowser, platform, managerPort } = {}) {
5358
5420
  if (String(openBrowser) !== '1') return false;
5359
5421
  if (platform !== 'darwin') return false;
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 { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, parseRequestedWorkspaceDir } from './lib.mjs';
23
+ import { bootBanner, bootDestination, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, parseRequestedWorkspaceDir } from './lib.mjs';
24
24
  import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
26
26
  import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
@@ -958,6 +958,26 @@ function addGoalMessage(goal, { from, via, text, at } = {}) {
958
958
  saveGoal(goal);
959
959
  return msg;
960
960
  }
961
+ // The agent's first words back, put where a person looks for an answer.
962
+ //
963
+ // A request used to produce nothing that reads as a reply until the run ended —
964
+ // minutes later. The output was never missing (every tool call streams into the
965
+ // run log), but the run log is a log: `✎ …` lines clipped to 110 characters,
966
+ // interleaved with `Edit app.js`. Masa's friend sent one message and waited three
967
+ // minutes for something that never came, because the thing that came did not look
968
+ // like an answer.
969
+ //
970
+ // So the FIRST thing the worker says on a run is recorded as its message on the
971
+ // goal, verbatim — the same place its final report already lands. Galda writes
972
+ // nothing of its own here and picks nothing: what to open with is the agent's
973
+ // choice, and this only carries it.
974
+ const workerOpenedWith = new Set();
975
+ function sayFromWorker(goal, task, text) {
976
+ if (!goal || !task || workerOpenedWith.has(task.id)) return;
977
+ workerOpenedWith.add(task.id);
978
+ addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
979
+ }
980
+
961
981
  function readGoalMessages(goalId) {
962
982
  try { return parseGoalMessages(readFileSync(goalMessagesFile(goalId), 'utf8')); }
963
983
  catch { return []; }
@@ -1271,7 +1291,7 @@ function sendTodos(t, todos) {
1271
1291
  }
1272
1292
 
1273
1293
  // ---- claude helpers --------------------------------------------------------
1274
- function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort, permissionMode = 'acceptEdits', onChild }) {
1294
+ function runClaude({ prompt, cwd, tools, onEvent, onTodos, onSay, resume, model, effort, permissionMode = 'acceptEdits', onChild }) {
1275
1295
  return new Promise((done) => {
1276
1296
  const args = [
1277
1297
  '-p', prompt,
@@ -1299,6 +1319,13 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1299
1319
  if (ev.kind === 'result') { result = ev.text; usage = ev.usage; }
1300
1320
  else if (ev.kind === 'session') sessionId = ev.id;
1301
1321
  else if (ev.kind === 'todos') onTodos?.(ev.todos);
1322
+ else if (ev.kind === 'say') {
1323
+ // Two things from one event: the short line the run log has always
1324
+ // shown, and the words themselves, so the caller can put the first
1325
+ // ones in front of the person as an actual reply.
1326
+ const short = `✎ ${ev.text.replace(/\s+/g, ' ').slice(0, 110)}`;
1327
+ lastAct = short; onEvent?.(short); onSay?.(ev.text);
1328
+ }
1302
1329
  else { if (ev.text?.trim()) lastAct = ev.text.trim(); onEvent?.(ev.text); }
1303
1330
  }
1304
1331
  }
@@ -1314,7 +1341,7 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1314
1341
  // The Codex half of the worker bridge. Same contract as runClaude, because
1315
1342
  // nothing downstream may care which agent ran: live activity, the agent's own
1316
1343
  // to-dos (the board), the thread id to talk to it again, usage, final message.
1317
- function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, images, onChild }) {
1344
+ function runCodex({ prompt, cwd, onEvent, onTodos, onSay, resume, permissionMode = 'acceptEdits', model, effort, images, onChild }) {
1318
1345
  return new Promise((done) => {
1319
1346
  const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
1320
1347
  const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume, images });
@@ -1343,6 +1370,7 @@ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acc
1343
1370
  else if (ev.kind === 'message') {
1344
1371
  result = ev.text;
1345
1372
  onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1373
+ onSay?.(ev.text);
1346
1374
  } else if (ev.kind === 'error') {
1347
1375
  err += `${ev.text}\n`;
1348
1376
  onEvent?.(`Error ${ev.text}`);
@@ -3004,6 +3032,7 @@ async function runTask(task) {
3004
3032
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3005
3033
  images: goal?.images,
3006
3034
  onTodos: (todos) => sendTodos(task, todos),
3035
+ onSay: (text) => sayFromWorker(goal, task, text),
3007
3036
  onChild: (c) => trackGoalWorker(goal?.id, c),
3008
3037
  });
3009
3038
  if (goal?.cancelled) return; // goal deleted mid-run → don't resurrect the task / run the gate
@@ -3093,6 +3122,7 @@ async function runTask(task) {
3093
3122
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3094
3123
  images: goal?.images,
3095
3124
  onTodos: (todos) => sendTodos(task, todos),
3125
+ onSay: (text) => sayFromWorker(goal, task, text),
3096
3126
  onChild: (c) => trackGoalWorker(goal?.id, c),
3097
3127
  });
3098
3128
  if (goal?.cancelled) return; // deleted mid-run → stop, don't run verify/PR
@@ -3122,6 +3152,7 @@ async function runTask(task) {
3122
3152
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3123
3153
  images: goal?.images,
3124
3154
  onTodos: (todos) => sendTodos(task, todos),
3155
+ onSay: (text) => sayFromWorker(goal, task, text),
3125
3156
  onChild: (c) => trackGoalWorker(goal?.id, c),
3126
3157
  });
3127
3158
  if (goal?.cancelled) return;
@@ -4708,19 +4739,21 @@ const server = createServer(async (req, res) => {
4708
4739
  try {
4709
4740
  const input = JSON.parse(body || '{}');
4710
4741
  if (typeof input.name === 'string' && input.name.trim()) project.name = input.name.trim().slice(0, 80);
4711
- // Moving a project to another folder. Refused loudly when the path is not
4712
- // somewhere work can happen: a folder that does not exist, or is HOME itself,
4713
- // or is not a git repository. Accepting it here would look like it worked and
4714
- // then fail silently on the next goal — the worst of the two failures, and the
4715
- // reason the "which folder?" card exists at all.
4742
+ // Moving a project to another folder. The only thing refused is a folder
4743
+ // that is not there a path with nothing behind it is a typo, and every
4744
+ // later failure would be reported somewhere far away from the mistake.
4745
+ //
4746
+ // "Not a git repository" used to be refused here too. It is not our call
4747
+ // (Masa 2026-07-31): `claude -p` in a plain folder simply works, and a
4748
+ // person who points Galda at one has said what they want. Such a folder
4749
+ // gets no isolated worktree and can produce no pull request; that is a
4750
+ // property of the folder, said where those things happen, not a reason to
4751
+ // reject the choice.
4716
4752
  if (input.dir !== undefined) {
4717
4753
  const dir = String(input.dir ?? '').trim();
4718
4754
  if (!dir) return json(res, 400, { error: 'folder is required' });
4719
4755
  const abs = dir.startsWith('~') ? join(HOME, dir.slice(1)) : resolve(dir);
4720
4756
  if (!existsSync(abs)) return json(res, 400, { error: 'no such folder', dir: abs });
4721
- if (needsProjectFolder({ dir: abs, homeDir: HOME, isRepo: isGitRepo(abs) })) {
4722
- return json(res, 400, { error: 'not a git repository', dir: abs });
4723
- }
4724
4757
  project.dir = abs;
4725
4758
  }
4726
4759
  saveProjects();
@@ -4859,24 +4892,30 @@ const server = createServer(async (req, res) => {
4859
4892
  // no PR) — the reviewer just Approves (files it) or Dismisses (drops
4860
4893
  // it). This is how "needs my judgment" reaches the phone without kicking
4861
4894
  // off an implementation run.
4862
- // Onboarding funnel (docs/HANDOFF-ONBOARDING-conversational.md): a real
4863
- // implementation goal can only produce changes inside a real code repo.
4864
- // If the active project points at a non-repo (e.g. the HOME dir when the
4865
- // agent was started ad-hoc), running would no-op — nothing to edit — yet
4866
- // report "done". Instead of that fake "fixed", ask which folder first,
4867
- // reusing the needsInput clarifying card; the answer (POST /answer)
4868
- // rebinds this goal to the chosen repo, then planning proceeds. Skipped
4869
- // for pending/review items (they never spawn a worker).
4895
+ // No folder question (Masa 2026-07-31: "フォルダは先に聞かないでいい…選びたい
4896
+ // 人が選べばいい", and asked for parity with Claude Code, which was then
4897
+ // measured: `claude -p` in a non-git folder just runs it never asks which
4898
+ // folder and never git-inits one).
4899
+ //
4900
+ // The first thing a new person met used to be an interrogation: which
4901
+ // folder, then whether they wanted PRs, before a single word of their
4902
+ // request was acted on. The folder half is gone. The folder is whatever
4903
+ // the CLI was started in, shown on the composer chip, and changeable
4904
+ // there by anyone who cares — which is the only place it was ever a real
4905
+ // choice rather than a toll gate.
4906
+ //
4907
+ // The old worry (a non-repo folder means the worker edits nothing and we
4908
+ // still say "done") is not answered by asking, and never was: goalWorkDir
4909
+ // already falls back to the project folder itself when there is no git
4910
+ // repo to cut a worktree from, so the work happens where the person is
4911
+ // looking, exactly as Claude Code does. What such a folder cannot do is
4912
+ // produce a pull request, and that is stated where PRs are decided, not
4913
+ // guarded here.
4870
4914
  const runnable = !pending && !review && !approval;
4871
- const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
4872
4915
  const lang = /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
4873
- const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
4874
- // Task 5 (PR運用ヒアリング): once the folder is settled, ask — once per
4875
- // project, before its first runnable goal whether this project wants
4876
- // PRs. Skipped this round if a folder question already claimed the
4877
- // needsInput slot; that goal gets rebound to a real repo via /answer
4878
- // and the NEXT goal for that project is what actually asks this.
4879
- const needsReviewPref = runnable && !needsFolder && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
4916
+ // Task 5 (PR運用ヒアリング): ask once per project, before its first
4917
+ // runnable goal whether this project wants PRs.
4918
+ const needsReviewPref = runnable && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
4880
4919
  const reviewPrefQuestion = needsReviewPref ? buildReviewPreferenceQuestion({ lang }) : undefined;
4881
4920
  if (needsReviewPref) { project.reviewPrefAsked = true; saveProjects(); }
4882
4921
  // Clamp to whatever agent CLIs are actually installed on this machine
@@ -4892,8 +4931,8 @@ const server = createServer(async (req, res) => {
4892
4931
  if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
4893
4932
  const goal = {
4894
4933
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
4895
- status: (needsFolder || needsReviewPref) ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4896
- question: folderQuestion || reviewPrefQuestion,
4934
+ status: needsReviewPref ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4935
+ question: reviewPrefQuestion,
4897
4936
  pauseReason: approval ? 'approval_required' : undefined,
4898
4937
  approvalRequest: approval ? {
4899
4938
  type: 'approval_required',
@@ -4947,7 +4986,7 @@ const server = createServer(async (req, res) => {
4947
4986
  // pending = deliberately shelved (Phase 2 material): never planned,
4948
4987
  // never queued, until POST /api/goals/:id/activate. review-only items
4949
4988
  // are terminal-until-human too, so they also skip planGoal.
4950
- if (runnable && !needsFolder && !needsReviewPref) startNextStacked(goal.projectId);
4989
+ if (runnable && !needsReviewPref) startNextStacked(goal.projectId);
4951
4990
  json(res, 200, goal);
4952
4991
  } catch { json(res, 400, { error: 'bad json' }); }
4953
4992
  });
@@ -6027,7 +6066,11 @@ async function answerGoalQuestion(goal, question) {
6027
6066
  const handoffText = crossProject ? [
6028
6067
  `[Cross-project implementation handoff from #${goal.id}]`,
6029
6068
  `Original request: ${goal.text}`,
6030
- goal.reviewSummary?.summary ? `Reviewed result: ${goal.reviewSummary.summary}` : '',
6069
+ // `.changed` is the reviewed result summarizeForReview returns
6070
+ // {title, check, changed} and never a `.summary`, so this line read a
6071
+ // key that has never existed and dropped out of every handoff. The
6072
+ // digest builder above already spells it `g.reviewSummary?.changed`.
6073
+ goal.reviewSummary?.changed ? `Reviewed result: ${goal.reviewSummary.changed}` : '',
6031
6074
  latest?.result ? `Latest worker result: ${String(latest.result).slice(0, 3000)}` : '',
6032
6075
  `User's implementation request: ${text}`,
6033
6076
  ].filter(Boolean).join('\n\n') : text;
@@ -6261,7 +6304,25 @@ server.listen(PORT, '127.0.0.1', () => {
6261
6304
  }
6262
6305
  PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
6263
6306
  try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
6264
- console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
6307
+ const localUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
6308
+ // WHERE this person's Galda is depends on how it was installed, not on whether
6309
+ // we managed to open a tab. `npx @galda/cli` sets the billing + app URLs, and
6310
+ // those people live in the hosted app; the localhost ?key board is the entry
6311
+ // only for local/dev (MANAGER_BILLING_API_URL= opt-out, or `npm start` in the
6312
+ // repo). Deciding this from the auto-open branch — as this first did — told a
6313
+ // hosted user on Linux, or anyone with MANAGER_OPEN_BROWSER=0, to open a
6314
+ // localhost board with an access key: the one thing the flow below has always
6315
+ // refused to do.
6316
+ const hostedFlow = Boolean(BILLING_API_URL && APP_URL);
6317
+ const alreadySignedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
6318
+ // buildSigninUrl() also starts the background claim poll — which is exactly
6319
+ // what a person about to open that link by hand needs running, so it is called
6320
+ // only on the branch that will actually show it.
6321
+ const { url: bannerUrl, mode: bannerMode } = bootDestination({
6322
+ hosted: hostedFlow, signedIn: alreadySignedIn, localUrl, appUrl: APP_URL,
6323
+ signinUrl: hostedFlow && !alreadySignedIn ? buildSigninUrl() : '',
6324
+ });
6325
+ let bannerOpened = false;
6265
6326
  if (shouldOpenBrowserOnBoot({ openBrowser: process.env.MANAGER_OPEN_BROWSER, platform: process.platform, managerPort: process.env.MANAGER_PORT })) {
6266
6327
  // Hosted flow (billing worker + named app configured): the user lives in
6267
6328
  // app.galda.app, driven over the relay — they must NEVER be dropped on a
@@ -6270,18 +6331,10 @@ server.listen(PORT, '127.0.0.1', () => {
6270
6331
  // → one-click Google sign-in; already signed in → straight to the app (the
6271
6332
  // board comes alive as relay-client rebinds via its license.token watcher).
6272
6333
  // The localhost ?key board stays the entry ONLY for local/dev (no billing).
6273
- let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
6274
- if (BILLING_API_URL && APP_URL) {
6275
- const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
6276
- openUrl = signedIn
6277
- ? APP_URL
6278
- : buildSigninUrl(); // device-flow: also starts the background claim poll
6279
- console.log(signedIn
6280
- ? `[manager] opening your app → ${APP_URL}`
6281
- : `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
6282
- }
6283
- spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
6334
+ bannerOpened = true;
6335
+ spawn('open', ['-g', bannerUrl], { stdio: 'ignore' }).unref();
6284
6336
  }
6337
+ for (const line of bootBanner({ url: bannerUrl, mode: bannerMode, opened: bannerOpened })) console.log(line);
6285
6338
  console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
6286
6339
  if (shouldProbeClaudeAuth(AVAILABLE_AGENTS)) probeWorkerAuth().catch(() => {}); // hold Claude tasks + banner if Claude is unavailable
6287
6340
  setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.114",
3
+ "version": "0.10.116",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {