@galda/cli 0.10.17 → 0.10.20
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 +43 -1
- package/app/index.html +376 -62
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +492 -12
- package/engine/relay-client.mjs +137 -32
- package/engine/server.mjs +445 -21
- package/package.json +3 -3
package/engine/server.mjs
CHANGED
|
@@ -13,15 +13,15 @@
|
|
|
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, readdirSync } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
17
17
|
import { emitEvent } from './analytics-client.mjs';
|
|
18
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
18
|
+
import { randomBytes, createHash, randomUUID } 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, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, 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, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse } from './lib.mjs';
|
|
23
|
+
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, 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, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -31,7 +31,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
31
31
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
32
32
|
?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
|
|
33
33
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
34
|
-
|
|
34
|
+
// MANAGER_PORT=0 asks the OS for a free port; the real one is only known once
|
|
35
|
+
// the listen below succeeds, so this is rebound there (before anything prints
|
|
36
|
+
// or links to it).
|
|
37
|
+
let PORT = Number(process.env.MANAGER_PORT ?? 4400);
|
|
35
38
|
|
|
36
39
|
// Access key: required for every request when the server is exposed beyond
|
|
37
40
|
// localhost (e.g. through a tunnel). Auto-generated once, kept out of git.
|
|
@@ -471,7 +474,7 @@ if (!existsSync(projectsFile)) {
|
|
|
471
474
|
writeFileSync(projectsFile, JSON.stringify([{ id: 'default', name: basename(cwd), dir: cwd }], null, 2));
|
|
472
475
|
}
|
|
473
476
|
const projects = JSON.parse(readFileSync(projectsFile, 'utf8'));
|
|
474
|
-
function saveProjects() { writeFileSync(projectsFile, JSON.stringify(projects, null, 2)); }
|
|
477
|
+
function saveProjects() { writeFileSync(projectsFile, JSON.stringify(projects, null, 2)); markSyncDirty(); }
|
|
475
478
|
function uniqueProjectId(name = 'project') {
|
|
476
479
|
const base = String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 28) || 'project';
|
|
477
480
|
let id = base, n = 2;
|
|
@@ -487,16 +490,49 @@ const logFile = join(logDir, 'tasks.jsonl');
|
|
|
487
490
|
// four buckets goalGroupStatus/goalChip actually track (see lib.mjs).
|
|
488
491
|
const workflowColumnsFile = join(DATA_DIR, 'workflow-columns.json');
|
|
489
492
|
let workflowColumns = existsSync(workflowColumnsFile) ? JSON.parse(readFileSync(workflowColumnsFile, 'utf8')) : {};
|
|
490
|
-
function saveWorkflowColumns() { writeFileSync(workflowColumnsFile, JSON.stringify(workflowColumns, null, 2)); }
|
|
493
|
+
function saveWorkflowColumns() { writeFileSync(workflowColumnsFile, JSON.stringify(workflowColumns, null, 2)); markSyncDirty(); }
|
|
491
494
|
function getColumns(projectId) { return workflowColumns[projectId] ?? DEFAULT_WORKFLOW_COLUMNS; }
|
|
492
495
|
|
|
493
496
|
// Per-project review definition (task 45): configurable conditions for when
|
|
494
497
|
// a finished goal lands in 'review' vs 'done'/'partial' — see lib.mjs.
|
|
495
498
|
const reviewDefinitionsFile = join(DATA_DIR, 'review-definitions.json');
|
|
496
499
|
let reviewDefinitions = existsSync(reviewDefinitionsFile) ? JSON.parse(readFileSync(reviewDefinitionsFile, 'utf8')) : {};
|
|
497
|
-
function saveReviewDefinitions() { writeFileSync(reviewDefinitionsFile, JSON.stringify(reviewDefinitions, null, 2)); }
|
|
500
|
+
function saveReviewDefinitions() { writeFileSync(reviewDefinitionsFile, JSON.stringify(reviewDefinitions, null, 2)); markSyncDirty(); }
|
|
498
501
|
function getReviewDefinition(projectId) { return reviewDefinitions[projectId] ?? DEFAULT_REVIEW_DEFINITION; }
|
|
499
502
|
|
|
503
|
+
// ---- cross-device board sync state (P3, docs/design/CROSS-DEVICE-SYNC-PLAN.md)
|
|
504
|
+
// rev = the optimistic-lock version this device last saw/wrote; writerId = a
|
|
505
|
+
// stable per-install id so the store can tell which device last wrote. Kept in
|
|
506
|
+
// its own tiny file next to the other state so it survives restarts. The push/
|
|
507
|
+
// pull machinery itself is defined further down (function declarations hoist);
|
|
508
|
+
// this only sets up the persisted rev/writerId it needs.
|
|
509
|
+
const SYNC_PUSH_DEBOUNCE_MS = 2500;
|
|
510
|
+
const SYNC_HTTP_TIMEOUT_MS = 5000; // defined here (not in the push/pull block below) so the boot-time `await bootSyncPull()` can read it — a `const` there would be in its TDZ at boot
|
|
511
|
+
const syncMetaFile = join(DATA_DIR, 'sync-meta.json');
|
|
512
|
+
let syncMeta = { rev: 0, writerId: null };
|
|
513
|
+
try { if (existsSync(syncMetaFile)) syncMeta = { ...syncMeta, ...JSON.parse(readFileSync(syncMetaFile, 'utf8')) }; }
|
|
514
|
+
catch { /* corrupt meta → start clean (a fresh pull re-establishes rev) */ }
|
|
515
|
+
if (!syncMeta.writerId) syncMeta.writerId = randomUUID();
|
|
516
|
+
function saveSyncMeta() { try { writeFileSync(syncMetaFile, JSON.stringify(syncMeta)); } catch { /* best-effort */ } }
|
|
517
|
+
saveSyncMeta();
|
|
518
|
+
let _syncDisabled = false; // set when the store says 501 (no bucket provisioned) — stop hammering
|
|
519
|
+
let _syncHydrating = false; // true while applying a pulled board, so our own saves don't re-arm a push
|
|
520
|
+
let _syncPushTimer = null;
|
|
521
|
+
let _syncPushInFlight = false;
|
|
522
|
+
let _syncDirtyAgain = false; // a save landed mid-push → coalesce into one follow-up push
|
|
523
|
+
|
|
524
|
+
// Which worker agent CLIs are installed on this machine — the launcher
|
|
525
|
+
// (bin/manager-for-ai.mjs) detects them (codex via a PATH lookup, never executed)
|
|
526
|
+
// and passes the set here. Galda offers only these, so a Codex-only machine gets
|
|
527
|
+
// a working board and a claude-only one never shows Codex. Defaults to Claude
|
|
528
|
+
// Code when unset (tests / running the server directly).
|
|
529
|
+
const envAgents = (process.env.MANAGER_AVAILABLE_AGENTS || 'claude-code')
|
|
530
|
+
.split(',').map((a) => a.trim()).filter(Boolean);
|
|
531
|
+
const AVAILABLE_AGENTS = resolveConnectedAgents({
|
|
532
|
+
claude: envAgents.includes('claude-code'),
|
|
533
|
+
codex: envAgents.includes('codex'),
|
|
534
|
+
});
|
|
535
|
+
|
|
500
536
|
// ---- state ---------------------------------------------------------------
|
|
501
537
|
// Restarts must not lose pending work: tasks that never started stay
|
|
502
538
|
// queued; only mid-run work becomes 'interrupted' (retryable). Goals that
|
|
@@ -508,6 +544,14 @@ let tasks = replayed.tasks;
|
|
|
508
544
|
let nextId = replayed.nextId;
|
|
509
545
|
let prioCounter = replayed.prioCounter;
|
|
510
546
|
|
|
547
|
+
// Cross-device sync (P3): before anything downstream (orphan reconcile, the
|
|
548
|
+
// queues Map, workers) is built from this state, adopt a newer board from the
|
|
549
|
+
// account if one exists — this is where a second device inherits the work on
|
|
550
|
+
// boot. No-op unless billing + a license token are configured (local / dev /
|
|
551
|
+
// free installs stay fully local), and time-boxed so a dead network can't hang
|
|
552
|
+
// startup. If nothing to pull but we have local work, seed the store instead.
|
|
553
|
+
await bootSyncPull();
|
|
554
|
+
|
|
511
555
|
// Reap goals a dead server left with no live worker (Masa dogfood: the To Do
|
|
512
556
|
// pile-up from a goal that never actually resumes) — see reconcileOrphanGoals
|
|
513
557
|
// in lib.mjs for the exact rule (unit-tested there). Computed and applied to
|
|
@@ -591,7 +635,7 @@ function sendGoalEvent(obj, goal) {
|
|
|
591
635
|
// proceed regardless. Durability is best-effort here; a live UI wins over a
|
|
592
636
|
// perfectly-replayable log on a disk that can't be written to anyway.
|
|
593
637
|
function logAppend(line) {
|
|
594
|
-
try { appendFileSync(logFile, line + '\n'); return true; }
|
|
638
|
+
try { appendFileSync(logFile, line + '\n'); markSyncDirty(); return true; }
|
|
595
639
|
catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
|
|
596
640
|
}
|
|
597
641
|
function saveGoal(g) { logAppend(JSON.stringify({ ...g, kind: 'goal' })); sendGoalEvent({ ev: 'goal', goal: g }, g); }
|
|
@@ -660,6 +704,202 @@ function postHistory(t) {
|
|
|
660
704
|
}).catch(() => {});
|
|
661
705
|
} catch { /* never throw into the caller */ }
|
|
662
706
|
}
|
|
707
|
+
// ---- cross-device board sync: push / pull (P3) ----------------------------
|
|
708
|
+
// docs/design/CROSS-DEVICE-SYNC-PLAN.md. This process owns the board (it holds
|
|
709
|
+
// the state + the license token); it pushes a debounced snapshot to the sync
|
|
710
|
+
// Worker (P2) and pulls on boot so a second device inherits the work. Opt-in:
|
|
711
|
+
// disabled unless a billing URL AND a license token are present, and it degrades
|
|
712
|
+
// silently (501 = bucket not provisioned) so local/dev/free installs are never
|
|
713
|
+
// affected. Pure decisions live in lib.mjs (buildBoardSnapshot / decideBoardPull).
|
|
714
|
+
// SYNC_HTTP_TIMEOUT_MS is declared up with the sync state (boot reads it early).
|
|
715
|
+
function syncEnabled() { return Boolean(BILLING_API_URL) && !_syncDisabled && Boolean(readLicenseToken()); }
|
|
716
|
+
function currentBoard() {
|
|
717
|
+
return buildBoardSnapshot({
|
|
718
|
+
projects,
|
|
719
|
+
tasksLog: existsSync(logFile) ? readFileSync(logFile, 'utf8') : '',
|
|
720
|
+
workflowColumns,
|
|
721
|
+
reviewDefinitions,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function localHasWork() { return goals.length > 0; }
|
|
725
|
+
// Overwrite in-memory + on-disk state from a validated remote board. Only ever
|
|
726
|
+
// called at boot (before queues/workers exist), so it can safely reassign the
|
|
727
|
+
// module-level state vars and rewrite the append-only log wholesale — a pull is
|
|
728
|
+
// a full replacement, not a merge. nextId/prioCounter come from the replay so
|
|
729
|
+
// ids can't collide with the adopted goals/tasks.
|
|
730
|
+
function hydrateFromBoard(snap) {
|
|
731
|
+
_syncHydrating = true;
|
|
732
|
+
try {
|
|
733
|
+
projects.length = 0; // `const` array → mutate in place
|
|
734
|
+
for (const p of snap.projects) projects.push(p);
|
|
735
|
+
saveProjects();
|
|
736
|
+
workflowColumns = snap.workflowColumns; saveWorkflowColumns();
|
|
737
|
+
reviewDefinitions = snap.reviewDefinitions; saveReviewDefinitions();
|
|
738
|
+
try { writeFileSync(logFile, snap.tasksLog); }
|
|
739
|
+
catch (e) { console.log(`[sync] hydrate: log write failed (${e?.code || e})`); }
|
|
740
|
+
const r = replayQueueLog(snap.tasksLog || '', reviewDefinitions);
|
|
741
|
+
goals = r.goals; tasks = r.tasks; nextId = r.nextId; prioCounter = r.prioCounter;
|
|
742
|
+
// A pulled 'running' goal has no live worker on this device — reconcile it
|
|
743
|
+
// exactly as a restart would (same rule replayQueueLog callers use at boot).
|
|
744
|
+
for (const fix of reconcileOrphanGoals(goals, tasks)) {
|
|
745
|
+
const g = goals.find((x) => x.id === fix.id);
|
|
746
|
+
if (g) { g.status = fix.status; g.blocked = null; g.prError = fix.reason; }
|
|
747
|
+
}
|
|
748
|
+
} finally { _syncHydrating = false; }
|
|
749
|
+
}
|
|
750
|
+
// Schedule a debounced push. Coalesces bursts of saves into one PUT, and never
|
|
751
|
+
// re-arms while we're applying a pulled board (that would ping-pong).
|
|
752
|
+
function markSyncDirty() {
|
|
753
|
+
if (_syncHydrating || !syncEnabled()) return;
|
|
754
|
+
if (_syncPushTimer) return;
|
|
755
|
+
_syncPushTimer = setTimeout(() => { _syncPushTimer = null; pushBoard(); }, SYNC_PUSH_DEBOUNCE_MS);
|
|
756
|
+
if (_syncPushTimer.unref) _syncPushTimer.unref(); // never keep the process alive for a pending push
|
|
757
|
+
}
|
|
758
|
+
async function pushBoard() {
|
|
759
|
+
if (!syncEnabled()) return;
|
|
760
|
+
if (_syncPushInFlight) { _syncDirtyAgain = true; return; }
|
|
761
|
+
_syncPushInFlight = true;
|
|
762
|
+
try {
|
|
763
|
+
const token = readLicenseToken();
|
|
764
|
+
const res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
765
|
+
method: 'PUT',
|
|
766
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
767
|
+
body: JSON.stringify({ baseRev: syncMeta.rev, board: currentBoard(), writerId: syncMeta.writerId }),
|
|
768
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
769
|
+
});
|
|
770
|
+
if (res.status === 501) { _syncDisabled = true; console.log('[sync] board store not provisioned (501) — cross-device sync disabled this session'); return; }
|
|
771
|
+
if (res.status === 409) {
|
|
772
|
+
// Another device advanced the board first. Cross-device is takeover, not
|
|
773
|
+
// concurrent editing (a non-goal): adopt the remote rev as our new base
|
|
774
|
+
// and re-push our board on top (last-writer-wins), so we converge in one
|
|
775
|
+
// extra round instead of hydrating mid-run.
|
|
776
|
+
const j = await res.json().catch(() => ({}));
|
|
777
|
+
if (typeof j.rev === 'number') { syncMeta.rev = j.rev; saveSyncMeta(); }
|
|
778
|
+
console.log(`[sync] push stale — rebased onto remote rev ${j.rev ?? '?'} and re-pushing`);
|
|
779
|
+
_syncDirtyAgain = true;
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (!res.ok) { console.log(`[sync] push failed (HTTP ${res.status})`); return; }
|
|
783
|
+
const j = await res.json().catch(() => ({}));
|
|
784
|
+
if (typeof j.rev === 'number') { syncMeta.rev = j.rev; saveSyncMeta(); }
|
|
785
|
+
} catch (e) { console.log(`[sync] push error: ${e?.message || e}`); }
|
|
786
|
+
finally {
|
|
787
|
+
_syncPushInFlight = false;
|
|
788
|
+
if (_syncDirtyAgain) { _syncDirtyAgain = false; markSyncDirty(); }
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// Boot-time pull. Adopts a newer remote board (device inherits the work); if
|
|
792
|
+
// nothing to adopt but this device has local work, seeds the store so the FIRST
|
|
793
|
+
// device populates it. Time-boxed; any failure leaves local state untouched.
|
|
794
|
+
async function bootSyncPull() {
|
|
795
|
+
if (!BILLING_API_URL || _syncDisabled) return;
|
|
796
|
+
const token = readLicenseToken();
|
|
797
|
+
if (!token) return;
|
|
798
|
+
let res;
|
|
799
|
+
try {
|
|
800
|
+
res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
801
|
+
headers: { authorization: `Bearer ${token}` },
|
|
802
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
803
|
+
});
|
|
804
|
+
} catch (e) { console.log(`[sync] boot pull skipped (${e?.name === 'TimeoutError' ? 'timeout' : e?.message || e}) — starting from local state`); return; }
|
|
805
|
+
if (res.status === 501) { _syncDisabled = true; console.log('[sync] board store not provisioned (501) — cross-device sync disabled this session'); return; }
|
|
806
|
+
if (!res.ok) { console.log(`[sync] boot pull failed (HTTP ${res.status}) — starting from local state`); return; }
|
|
807
|
+
const remote = await res.json().catch(() => ({}));
|
|
808
|
+
const remoteRev = Number(remote?.rev) || 0;
|
|
809
|
+
const remoteSnap = remote?.board ? validateBoardSnapshot(remote.board) : null;
|
|
810
|
+
const decision = decideBoardPull({
|
|
811
|
+
localRev: syncMeta.rev,
|
|
812
|
+
remoteRev,
|
|
813
|
+
localHasWork: localHasWork(),
|
|
814
|
+
remoteHasWork: remoteSnap ? !boardIsEmpty(remoteSnap) : false,
|
|
815
|
+
});
|
|
816
|
+
if (decision.pull) {
|
|
817
|
+
if (!remoteSnap) { console.log('[sync] boot pull: remote board invalid/unsupported version — ignored, using local state'); return; }
|
|
818
|
+
if (decision.clobbersLocal) console.log('[sync] WARNING: adopting the account board over un-synced local work (last-writer-wins; concurrent offline editing is not supported)');
|
|
819
|
+
hydrateFromBoard(remoteSnap);
|
|
820
|
+
syncMeta.rev = remoteRev; saveSyncMeta();
|
|
821
|
+
console.log(`[sync] board inherited from account (rev ${remoteRev})`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// Nothing newer to adopt. If we're the first device with real work, seed it.
|
|
825
|
+
if (localHasWork() && syncEnabled()) { console.log('[sync] seeding account board from local state'); markSyncDirty(); }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// True while this device is running any work of its own — a planning/running
|
|
829
|
+
// goal, or a live worker child. hydrateFromBoard rewrites the log wholesale and
|
|
830
|
+
// swaps goals/tasks, which would orphan an in-flight worker's writes, so a live
|
|
831
|
+
// takeover must refuse while busy (a boot pull never has this problem: nothing
|
|
832
|
+
// is running yet). `goalWorkerChildren` is declared below but only read here at
|
|
833
|
+
// request time, long after module init, so the forward reference is fine.
|
|
834
|
+
function deviceBusy() {
|
|
835
|
+
return goalWorkerChildren.size > 0 || goals.some((g) => ['planning', 'running'].includes(g.status));
|
|
836
|
+
}
|
|
837
|
+
// Live (mid-session) takeover pull (P4): adopt the account's newer board into
|
|
838
|
+
// this already-serving instance, on demand (the switch-UI's 切替 button). Boot
|
|
839
|
+
// pull (bootSyncPull) inherits on startup; this is the same adoption triggered
|
|
840
|
+
// while running. Refuses when this device is busy (deviceBusy) so it can never
|
|
841
|
+
// yank the board out from under a live worker. On a successful 'pull' this
|
|
842
|
+
// device now holds the account board at its rev; the caller reloads to render
|
|
843
|
+
// it. Returns { ok, action, rev } — see liveTakeoverDecision for the actions.
|
|
844
|
+
async function livePull() {
|
|
845
|
+
if (!syncEnabled()) return { ok: false, action: 'disabled' };
|
|
846
|
+
if (deviceBusy()) return { ok: false, action: 'busy' };
|
|
847
|
+
const token = readLicenseToken();
|
|
848
|
+
let res;
|
|
849
|
+
try {
|
|
850
|
+
res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
851
|
+
headers: { authorization: `Bearer ${token}` },
|
|
852
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
853
|
+
});
|
|
854
|
+
} catch (e) {
|
|
855
|
+
console.log(`[sync] live pull skipped (${e?.name === 'TimeoutError' ? 'timeout' : e?.message || e})`);
|
|
856
|
+
return { ok: false, action: 'error' };
|
|
857
|
+
}
|
|
858
|
+
if (res.status === 501) { _syncDisabled = true; return { ok: false, action: 'disabled' }; }
|
|
859
|
+
if (!res.ok) { console.log(`[sync] live pull failed (HTTP ${res.status})`); return { ok: false, action: 'error' }; }
|
|
860
|
+
const remote = await res.json().catch(() => ({}));
|
|
861
|
+
const remoteRev = Number(remote?.rev) || 0;
|
|
862
|
+
const remoteSnap = remote?.board ? validateBoardSnapshot(remote.board) : null;
|
|
863
|
+
const decision = liveTakeoverDecision({
|
|
864
|
+
syncEnabled: true,
|
|
865
|
+
busy: false,
|
|
866
|
+
remoteValid: Boolean(remoteSnap),
|
|
867
|
+
localRev: syncMeta.rev,
|
|
868
|
+
remoteRev,
|
|
869
|
+
localHasWork: localHasWork(),
|
|
870
|
+
remoteHasWork: remoteSnap ? !boardIsEmpty(remoteSnap) : false,
|
|
871
|
+
});
|
|
872
|
+
if (decision.action !== 'pull') return { ok: decision.action === 'current', action: decision.action, rev: syncMeta.rev };
|
|
873
|
+
if (decision.clobbersLocal) console.log('[sync] live takeover adopting account board over un-synced local work (last-writer-wins)');
|
|
874
|
+
hydrateFromBoard(remoteSnap);
|
|
875
|
+
syncMeta.rev = remoteRev; saveSyncMeta();
|
|
876
|
+
rebuildQueuesAfterAdopt();
|
|
877
|
+
console.log(`[sync] live takeover: board adopted from account (rev ${remoteRev})`);
|
|
878
|
+
return { ok: true, action: 'pull', rev: remoteRev };
|
|
879
|
+
}
|
|
880
|
+
// hydrateFromBoard replaces projects/goals/tasks, but the `queues` Map still
|
|
881
|
+
// mirrors the OLD board: adopted projects have no entry (so /api/state throws
|
|
882
|
+
// on queues.get(p.id).waiting), and adopted 'queued' tasks sit in no waiting
|
|
883
|
+
// list. A boot pull never hits this — the Map is built from projects *after*
|
|
884
|
+
// hydrate — but a live pull must rebuild it here. The pull was gated on
|
|
885
|
+
// deviceBusy() being false, so nothing is running and the Map can be rebuilt
|
|
886
|
+
// wholesale. This mirrors the boot re-queue/plan loop (server start, below):
|
|
887
|
+
// re-queue pending tasks, pump, and plan any 'stacked' goal so the inherited
|
|
888
|
+
// work actually resumes on this device — the whole point of a takeover.
|
|
889
|
+
function rebuildQueuesAfterAdopt() {
|
|
890
|
+
queues.clear();
|
|
891
|
+
for (const p of projects) queues.set(p.id, { running: new Set(), waiting: [] });
|
|
892
|
+
for (const p of projects) {
|
|
893
|
+
const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
|
|
894
|
+
queues.get(p.id).waiting.push(...pending);
|
|
895
|
+
pump(p.id);
|
|
896
|
+
for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
|
|
897
|
+
g.status = 'planning'; saveGoal(g);
|
|
898
|
+
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
663
903
|
const ACTIVITY_LIMIT = 1000;
|
|
664
904
|
function activityLine(line) {
|
|
665
905
|
return String(line ?? '')
|
|
@@ -777,6 +1017,13 @@ function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model,
|
|
|
777
1017
|
});
|
|
778
1018
|
});
|
|
779
1019
|
}
|
|
1020
|
+
// Small internal LLM calls (intent classification, proof summaries, board Q&A).
|
|
1021
|
+
// Prefer Claude Code + haiku, but fall back to whatever agent is installed so a
|
|
1022
|
+
// Codex-only machine still works — never a hardcoded `claude` that isn't there.
|
|
1023
|
+
function runUtility(args) {
|
|
1024
|
+
const agent = pickUtilityAgent(AVAILABLE_AGENTS);
|
|
1025
|
+
return runWorkerAgent({ agent, model: utilityModel(agent), ...args });
|
|
1026
|
+
}
|
|
780
1027
|
function runWorkerAgent({ agent, ...args }) {
|
|
781
1028
|
return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
|
|
782
1029
|
}
|
|
@@ -998,6 +1245,62 @@ function startNextStacked(projectId) {
|
|
|
998
1245
|
});
|
|
999
1246
|
}
|
|
1000
1247
|
|
|
1248
|
+
// Worker auth preflight state. The manager runs every worker by spawning a
|
|
1249
|
+
// standalone `claude` (runClaude), inheriting the launching shell's login. If
|
|
1250
|
+
// that shell has no usable credentials, every task fails late with "Not logged
|
|
1251
|
+
// in". We probe once at boot and hold the queue + raise a banner instead of
|
|
1252
|
+
// failing task after task. Optimistic default (ok:true) so we never falsely
|
|
1253
|
+
// block a correctly-authed setup during the ~1s probe.
|
|
1254
|
+
let workerAuth = { ok: true, reason: null, checkedAt: null };
|
|
1255
|
+
const publicAuth = () => ({ ok: workerAuth.ok, reason: workerAuth.reason });
|
|
1256
|
+
|
|
1257
|
+
// Boot preflight: one cheap Read-only `claude` call; classify the result (pure,
|
|
1258
|
+
// in lib) and broadcast so the UI can show/clear the banner. `claude -p` exits 0
|
|
1259
|
+
// even when logged out, so classifyAuthProbe reads the text, not the code.
|
|
1260
|
+
async function probeWorkerAuth() {
|
|
1261
|
+
try {
|
|
1262
|
+
const out = await runClaude({ prompt: 'Reply with the single word: ok', cwd: ROOT, tools: 'Read', model: 'haiku' });
|
|
1263
|
+
workerAuth = { ...classifyAuthProbe({ result: out?.result, code: out?.code }), checkedAt: Date.now() };
|
|
1264
|
+
} catch {
|
|
1265
|
+
workerAuth = { ok: false, reason: 'probe-failed', checkedAt: Date.now() };
|
|
1266
|
+
}
|
|
1267
|
+
console.log(workerAuth.ok
|
|
1268
|
+
? '[manager] worker auth preflight OK'
|
|
1269
|
+
: `[manager] worker auth preflight FAILED (${workerAuth.reason}) — holding the queue. ${authBannerText(workerAuth.reason, 'en')}`);
|
|
1270
|
+
send({ ev: 'auth', auth: publicAuth() });
|
|
1271
|
+
return workerAuth;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Layer 2 of the intent router: a general planner clarify card has no builder to
|
|
1275
|
+
// re-render from, so when the user asks to switch language mid-question we ask
|
|
1276
|
+
// the model (cheap, Read-only, no repo edits) to RESTATE the pending question in
|
|
1277
|
+
// the target language — same intent, same options, just translated. Keeps the
|
|
1278
|
+
// goal in needsInput (the user still hasn't answered). On any failure we keep the
|
|
1279
|
+
// original card so the user is never stranded. Pure parsing lives in
|
|
1280
|
+
// parseRelocalizedQuestion (unit-tested); this is the I/O shell.
|
|
1281
|
+
async function relocalizeGeneralQuestion(goal, lang, res) {
|
|
1282
|
+
const q0 = goal.question || {};
|
|
1283
|
+
const langName = lang === 'en' ? 'English' : 'Japanese (日本語)';
|
|
1284
|
+
const prompt = [
|
|
1285
|
+
`Restate the following clarifying question in ${langName}. Keep the meaning and each option; only translate. Do not answer it, do not add new options.`,
|
|
1286
|
+
`Question: ${q0.text ?? ''}`,
|
|
1287
|
+
Array.isArray(q0.options) && q0.options.length ? `Options: ${JSON.stringify(q0.options)}` : 'Options: []',
|
|
1288
|
+
'Return ONLY JSON: {"text": "...", "options": ["..."]}',
|
|
1289
|
+
].join('\n');
|
|
1290
|
+
try {
|
|
1291
|
+
const out = await runClaude({ prompt, cwd: ROOT, tools: 'Read', model: 'haiku' });
|
|
1292
|
+
const restated = parseRelocalizedQuestion(out?.result);
|
|
1293
|
+
goal.question = restated
|
|
1294
|
+
? { ...q0, text: restated.text, options: restated.options.length ? restated.options : (q0.options || []), lang }
|
|
1295
|
+
: { ...q0, lang };
|
|
1296
|
+
} catch {
|
|
1297
|
+
goal.question = { ...q0, lang };
|
|
1298
|
+
}
|
|
1299
|
+
goal.status = 'needsInput';
|
|
1300
|
+
saveGoal(goal);
|
|
1301
|
+
return json(res, 200, goal);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1001
1304
|
async function planGoal(goal) {
|
|
1002
1305
|
const project = projects.find((p) => p.id === goal.projectId);
|
|
1003
1306
|
const prompt = [
|
|
@@ -1114,6 +1417,10 @@ async function authorVerify(task, goal, project) {
|
|
|
1114
1417
|
// `lastStartAt` check in pump() below.
|
|
1115
1418
|
const slowModeLastStart = new Map(); // projectId -> ms epoch of the last task start
|
|
1116
1419
|
function pump(projectId) {
|
|
1420
|
+
// Worker auth preflight failed → hold the queue (the UI banner tells the user
|
|
1421
|
+
// to sign `claude` in and relaunch). Never spawn a worker that can only fail
|
|
1422
|
+
// with "Not logged in". Cleared automatically once a probe succeeds.
|
|
1423
|
+
if (!workerAuth.ok) return;
|
|
1117
1424
|
const q = queues.get(projectId);
|
|
1118
1425
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1119
1426
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -2075,7 +2382,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2075
2382
|
`ワーカー報告:\n${reports.slice(0, 1500)}`,
|
|
2076
2383
|
].join('\n');
|
|
2077
2384
|
try {
|
|
2078
|
-
const r = await
|
|
2385
|
+
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
2079
2386
|
const m = r.result.match(/\{[\s\S]*\}/);
|
|
2080
2387
|
if (m) { const o = JSON.parse(m[0]); return { check: String(o.check ?? '').replace(/\s+/g, ' ').slice(0, 120), changed: String(o.changed ?? '').replace(/\s+/g, ' ').slice(0, 120) || fallback.changed }; }
|
|
2081
2388
|
} catch { /* fall through */ }
|
|
@@ -2542,7 +2849,7 @@ const server = createServer(async (req, res) => {
|
|
|
2542
2849
|
price: await getBillingPrice(),
|
|
2543
2850
|
siteUrl: BILLING_API_URL || null,
|
|
2544
2851
|
};
|
|
2545
|
-
return json(res, 200, { projects, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents:
|
|
2852
|
+
return json(res, 200, { projects, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: AVAILABLE_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing, auth: publicAuth() });
|
|
2546
2853
|
}
|
|
2547
2854
|
|
|
2548
2855
|
// Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
|
|
@@ -2564,6 +2871,34 @@ const server = createServer(async (req, res) => {
|
|
|
2564
2871
|
return;
|
|
2565
2872
|
}
|
|
2566
2873
|
|
|
2874
|
+
// GET /api/relay-status — is this account's relay slot held by ANOTHER device?
|
|
2875
|
+
// The relay-client (a separate process) writes relay-status.json when it stands
|
|
2876
|
+
// down after being evicted (see relay-client.mjs / lib.mjs relayReconnectDecision).
|
|
2877
|
+
// P4's board reads this to show "Galda is running on another device → switch
|
|
2878
|
+
// here". Best-effort: no file yet → idle.
|
|
2879
|
+
if (url.pathname === '/api/relay-status' && req.method === 'GET') {
|
|
2880
|
+
let st = { status: 'idle', stoodDown: false, identity: null, at: 0 };
|
|
2881
|
+
try { st = { ...st, ...JSON.parse(readFileSync(join(DATA_DIR, 'relay-status.json'), 'utf8')) }; } catch { /* none yet */ }
|
|
2882
|
+
return json(res, 200, { ...st, inUseElsewhere: st.stoodDown === true });
|
|
2883
|
+
}
|
|
2884
|
+
// POST /api/relay-takeover — the user pressed "switch to this device". Signal
|
|
2885
|
+
// the relay-client (separate process) via a request file it watches; it clears
|
|
2886
|
+
// the stand-down and reclaims the relay slot. UI wiring is P4.
|
|
2887
|
+
if (url.pathname === '/api/relay-takeover' && req.method === 'POST') {
|
|
2888
|
+
try { writeFileSync(join(DATA_DIR, 'relay-takeover.req'), String(Date.now())); }
|
|
2889
|
+
catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
|
|
2890
|
+
return json(res, 200, { ok: true });
|
|
2891
|
+
}
|
|
2892
|
+
// POST /api/signout — forget the local license so the settings panel can
|
|
2893
|
+
// offer a plain "Log out" instead of requiring the `--signin` CLI flag to
|
|
2894
|
+
// switch Google accounts. Clears the on-disk token and the in-memory cache;
|
|
2895
|
+
// idempotent when already signed out.
|
|
2896
|
+
if (url.pathname === '/api/signout' && req.method === 'POST') {
|
|
2897
|
+
if (existsSync(licenseFile)) unlinkSync(licenseFile);
|
|
2898
|
+
licenseState = { email: null, isPaying: false, verifiedAt: 0 };
|
|
2899
|
+
return json(res, 200, { ok: true });
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2567
2902
|
// GET /api/signin-url — mints a one-time nonce and returns the billing
|
|
2568
2903
|
// Worker's /connect URL for "Sign in with Google". The app opens this in the
|
|
2569
2904
|
// browser; Google bounces back to /oauth/callback (below) with a token.
|
|
@@ -2632,6 +2967,15 @@ const server = createServer(async (req, res) => {
|
|
|
2632
2967
|
return json(res, 200, { licensed: true, isPaying: licenseState.isPaying });
|
|
2633
2968
|
}
|
|
2634
2969
|
|
|
2970
|
+
// Live cross-device takeover (P4): pull the account's newer board into this
|
|
2971
|
+
// running instance on demand (the switch UI's 切替 button). Adopts only when
|
|
2972
|
+
// this device is idle; 409 'busy' if it has active work (never yanks the board
|
|
2973
|
+
// from a live worker). Key-gated like every /api route above.
|
|
2974
|
+
if (url.pathname === '/api/sync/pull' && req.method === 'POST') {
|
|
2975
|
+
const r = await livePull();
|
|
2976
|
+
return json(res, r.action === 'busy' ? 409 : 200, r);
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2635
2979
|
if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
2636
2980
|
let body = '';
|
|
2637
2981
|
req.on('data', (d) => { body += d; });
|
|
@@ -2734,7 +3078,7 @@ const server = createServer(async (req, res) => {
|
|
|
2734
3078
|
// fall through, so a real implementation ask is never dropped.
|
|
2735
3079
|
const intentLang = detectRequestLanguage(text);
|
|
2736
3080
|
try {
|
|
2737
|
-
const r = await
|
|
3081
|
+
const r = await runUtility({ prompt: buildIntentPrompt(text.trim(), intentLang), cwd: ROOT, tools: 'Read' });
|
|
2738
3082
|
const parsed = parseIntentResponse(r.result);
|
|
2739
3083
|
if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
|
|
2740
3084
|
} catch { /* LLM unavailable → fall through to goal creation */ }
|
|
@@ -2784,15 +3128,25 @@ const server = createServer(async (req, res) => {
|
|
|
2784
3128
|
// for pending/review items (they never spawn a worker).
|
|
2785
3129
|
const runnable = !pending && !review;
|
|
2786
3130
|
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
3131
|
+
const lang = /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
|
|
3132
|
+
const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
|
|
3133
|
+
// Task 5 (PR運用ヒアリング): once the folder is settled, ask — once per
|
|
3134
|
+
// project, before its first runnable goal — whether this project wants
|
|
3135
|
+
// PRs. Skipped this round if a folder question already claimed the
|
|
3136
|
+
// needsInput slot; that goal gets rebound to a real repo via /answer
|
|
3137
|
+
// and the NEXT goal for that project is what actually asks this.
|
|
3138
|
+
const needsReviewPref = runnable && !needsFolder && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
|
|
3139
|
+
const reviewPrefQuestion = needsReviewPref ? buildReviewPreferenceQuestion({ lang }) : undefined;
|
|
3140
|
+
if (needsReviewPref) { project.reviewPrefAsked = true; saveProjects(); }
|
|
3141
|
+
// Clamp to whatever agent CLIs are actually installed on this machine
|
|
3142
|
+
// (connected-agents Phase A) — a requested/default agent that isn't
|
|
3143
|
+
// present would otherwise spawn a worker doomed to fail at launch.
|
|
3144
|
+
const goalAgent = pickAvailableAgent(workerAgent(agent), AVAILABLE_AGENTS);
|
|
2791
3145
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
2792
3146
|
const goal = {
|
|
2793
3147
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
2794
|
-
status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
2795
|
-
question: folderQuestion,
|
|
3148
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
3149
|
+
question: folderQuestion || reviewPrefQuestion,
|
|
2796
3150
|
reviewOnly: review ? true : undefined,
|
|
2797
3151
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
2798
3152
|
wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
@@ -2822,7 +3176,7 @@ const server = createServer(async (req, res) => {
|
|
|
2822
3176
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
2823
3177
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
2824
3178
|
// are terminal-until-human too, so they also skip planGoal.
|
|
2825
|
-
if (runnable && !needsFolder) {
|
|
3179
|
+
if (runnable && !needsFolder && !needsReviewPref) {
|
|
2826
3180
|
planGoal(goal).catch((e) => {
|
|
2827
3181
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
2828
3182
|
});
|
|
@@ -2857,7 +3211,7 @@ const server = createServer(async (req, res) => {
|
|
|
2857
3211
|
'',
|
|
2858
3212
|
`質問: ${question.trim().slice(0, 500)}`,
|
|
2859
3213
|
].join('\n');
|
|
2860
|
-
const r = await
|
|
3214
|
+
const r = await runUtility({ prompt, cwd: ROOT, tools: 'Read' });
|
|
2861
3215
|
json(res, 200, { answer: r.result.trim().slice(0, 2000) });
|
|
2862
3216
|
} catch (e) {
|
|
2863
3217
|
json(res, 500, { error: String(e.message ?? e).slice(0, 200) });
|
|
@@ -3138,6 +3492,52 @@ const server = createServer(async (req, res) => {
|
|
|
3138
3492
|
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3139
3493
|
return json(res, 200, goal);
|
|
3140
3494
|
};
|
|
3495
|
+
// Intent router (no free-text box is a dead-end). Before any per-kind
|
|
3496
|
+
// handler treats `answer` as a literal, classify it. A reply that isn't a
|
|
3497
|
+
// clean answer is NEVER coerced (the old bug where 「日本語で教えて」 typed
|
|
3498
|
+
// into the folder box became a repo of that name). Two non-answer routes:
|
|
3499
|
+
// relocalize → the user asked to switch language; re-render the SAME
|
|
3500
|
+
// card in that language (folder/init via their builders; a general
|
|
3501
|
+
// planner card via the model, which can restate in any language).
|
|
3502
|
+
// escalate → free-form intent; for the filesystem-bound folder cards we
|
|
3503
|
+
// re-ask (with a localized hint) rather than invent a path; for a
|
|
3504
|
+
// general card, prose is a legit clarification and routes 'accept'.
|
|
3505
|
+
const route = routeQuestionAnswer(answer, goal.question || {});
|
|
3506
|
+
const qKind = goal.question?.kind;
|
|
3507
|
+
if (route.route === 'relocalize') {
|
|
3508
|
+
goal.lang = route.lang;
|
|
3509
|
+
if (qKind === 'folder') {
|
|
3510
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang: route.lang });
|
|
3511
|
+
saveGoal(goal);
|
|
3512
|
+
return json(res, 200, goal);
|
|
3513
|
+
}
|
|
3514
|
+
if (qKind === 'folder-init') {
|
|
3515
|
+
const q0 = goal.question;
|
|
3516
|
+
goal.question = buildFolderInitConfirm(q0.dir, { lang: route.lang, create: q0.create, homeDir: HOME });
|
|
3517
|
+
saveGoal(goal);
|
|
3518
|
+
return json(res, 200, goal);
|
|
3519
|
+
}
|
|
3520
|
+
// General planner clarify card: no builder to rebuild from, so ask the
|
|
3521
|
+
// model (cheap, Read-only) to restate the pending question in `route.lang`.
|
|
3522
|
+
relocalizeGeneralQuestion(goal, route.lang, res);
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
if (route.route === 'escalate' && (qKind === 'folder' || qKind === 'folder-init')) {
|
|
3526
|
+
// Couldn't read prose as a folder/decision → re-ask WITHOUT coercion,
|
|
3527
|
+
// folding a localized hint into the card's text (no new UI field).
|
|
3528
|
+
const lang = goal.question?.lang || goal.lang || 'ja';
|
|
3529
|
+
if (qKind === 'folder-init') {
|
|
3530
|
+
const q0 = goal.question;
|
|
3531
|
+
goal.question = buildFolderInitConfirm(q0.dir, { lang, create: q0.create, homeDir: HOME });
|
|
3532
|
+
} else {
|
|
3533
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
|
|
3534
|
+
}
|
|
3535
|
+
goal.question.text = notUnderstoodNote(lang) + goal.question.text;
|
|
3536
|
+
saveGoal(goal);
|
|
3537
|
+
return json(res, 200, goal);
|
|
3538
|
+
}
|
|
3539
|
+
// route === 'accept' (or a general escalate, which folds into goal.text
|
|
3540
|
+
// below) → fall through to the existing per-kind handlers unchanged.
|
|
3141
3541
|
// Folder-choice answer (onboarding funnel): rebind the goal to the chosen
|
|
3142
3542
|
// repo rather than appending to goal.text — a folder isn't a task
|
|
3143
3543
|
// clarification. If the picked path is already a repo, bind it. If it's a
|
|
@@ -3195,6 +3595,28 @@ const server = createServer(async (req, res) => {
|
|
|
3195
3595
|
}
|
|
3196
3596
|
return bindAndPlan(dir);
|
|
3197
3597
|
}
|
|
3598
|
+
// Review-preference answer (task 5 onboarding funnel): fold into the
|
|
3599
|
+
// project's review definition (defaultWantsPR) rather than goal.text —
|
|
3600
|
+
// like the folder question, this is project setup, not a task detail.
|
|
3601
|
+
if (goal.question?.kind === 'reviewPref') {
|
|
3602
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
3603
|
+
const patch = resolveReviewPreferenceAnswer(answer, goal.question);
|
|
3604
|
+
if (project) {
|
|
3605
|
+
const merged = validateReviewDefinition({ ...getReviewDefinition(project.id), ...patch });
|
|
3606
|
+
if (merged.ok) {
|
|
3607
|
+
reviewDefinitions[project.id] = merged.definition;
|
|
3608
|
+
saveReviewDefinitions();
|
|
3609
|
+
send({ ev: 'review-definition', projectId: project.id, reviewDefinition: merged.definition });
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
|
|
3613
|
+
goal.question = null;
|
|
3614
|
+
goal.clarified = true;
|
|
3615
|
+
goal.status = 'planning';
|
|
3616
|
+
saveGoal(goal);
|
|
3617
|
+
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3618
|
+
return json(res, 200, goal);
|
|
3619
|
+
}
|
|
3198
3620
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
3199
3621
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
3200
3622
|
goal.question = null;
|
|
@@ -3539,7 +3961,6 @@ const server = createServer(async (req, res) => {
|
|
|
3539
3961
|
// Same request handler, loopback-only, best-effort (skip if ::1 unavailable).
|
|
3540
3962
|
const server6 = createServer(server.listeners('request')[0]);
|
|
3541
3963
|
server6.on('error', (e) => console.log(`[manager] ::1 listen skipped: ${e.code}`));
|
|
3542
|
-
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
3543
3964
|
|
|
3544
3965
|
// Guard the primary IPv4 listen the same way the ::1 twin is guarded: without
|
|
3545
3966
|
// this, a busy port emits an unhandled 'error' and Node prints a raw stack
|
|
@@ -3556,6 +3977,8 @@ server.on('error', (e) => {
|
|
|
3556
3977
|
});
|
|
3557
3978
|
|
|
3558
3979
|
server.listen(PORT, '127.0.0.1', () => {
|
|
3980
|
+
PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
|
|
3981
|
+
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
3559
3982
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
|
3560
3983
|
if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
|
|
3561
3984
|
// Hosted flow (billing worker + named app configured): the user lives in
|
|
@@ -3578,6 +4001,7 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
3578
4001
|
spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
|
|
3579
4002
|
}
|
|
3580
4003
|
console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
|
|
4004
|
+
probeWorkerAuth().catch(() => {}); // hold the queue + banner if `claude` isn't signed in
|
|
3581
4005
|
syncAllExternal().catch(() => {});
|
|
3582
4006
|
syncGoalMerges().catch(() => {});
|
|
3583
4007
|
setInterval(() => {
|