@evomap/evolver 1.89.20 → 2.0.0-beta.0
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/README.md +27 -564
- package/bin/evolver-llm-proxy.js +3 -0
- package/bin/evolver-mcp.js +2 -0
- package/bin/evolver-proxy.js +3 -0
- package/bin/evolver.js +4 -0
- package/index.js +1 -3586
- package/package.json +32 -62
- package/CONTRIBUTING.md +0 -19
- package/LICENSE +0 -641
- package/README.ja-JP.md +0 -521
- package/README.ko-KR.md +0 -520
- package/README.zh-CN.md +0 -531
- package/SKILL.md +0 -365
- package/assets/cover.png +0 -0
- package/assets/gep/genes.seed.json +0 -496
- package/conformance/savings-core/constants.json +0 -30
- package/conformance/savings-core/golden-vectors.json +0 -333
- package/scripts/a2a_export.js +0 -63
- package/scripts/a2a_ingest.js +0 -79
- package/scripts/a2a_promote.js +0 -118
- package/scripts/analyze_by_skill.js +0 -121
- package/scripts/build_binaries.js +0 -479
- package/scripts/check-changelog.js +0 -166
- package/scripts/extract_log.js +0 -85
- package/scripts/generate_history.js +0 -75
- package/scripts/gep_append_event.js +0 -96
- package/scripts/gep_personality_report.js +0 -234
- package/scripts/human_report.js +0 -147
- package/scripts/recall-verify-report.js +0 -234
- package/scripts/recover_loop.js +0 -61
- package/scripts/refresh_stars_badge.js +0 -168
- package/scripts/seed-merchants.js +0 -91
- package/scripts/skill2recipes.js +0 -118
- package/scripts/suggest_version.js +0 -89
- package/scripts/validate-modules.js +0 -38
- package/scripts/validate-suite.js +0 -78
- package/skills/_meta/SKILL.md +0 -41
- package/skills/index.json +0 -14
- package/src/adapters/claudeCode.js +0 -194
- package/src/adapters/codex.js +0 -216
- package/src/adapters/cursor.js +0 -91
- package/src/adapters/hookAdapter.js +0 -469
- package/src/adapters/kiro.js +0 -195
- package/src/adapters/opencode.js +0 -326
- package/src/adapters/scripts/_lockPaths.js +0 -74
- package/src/adapters/scripts/_memoryFiltering.js +0 -35
- package/src/adapters/scripts/_runtimePaths.js +0 -440
- package/src/adapters/scripts/evolver-session-end.js +0 -321
- package/src/adapters/scripts/evolver-session-start.js +0 -587
- package/src/adapters/scripts/evolver-signal-detect.js +0 -98
- package/src/adapters/scripts/evolver-task-recall.js +0 -173
- package/src/atp/atpExecute.js +0 -283
- package/src/atp/atpTaskPickup.js +0 -233
- package/src/atp/autoBuyer.js +0 -382
- package/src/atp/autoDeliver.js +0 -215
- package/src/atp/cli.js +0 -354
- package/src/atp/cliAutobuyPrompt.js +0 -154
- package/src/atp/consumerAgent.js +0 -157
- package/src/atp/defaultHandler.js +0 -69
- package/src/atp/heartbeatSignalsHandler.js +0 -254
- package/src/atp/hubClient.js +0 -317
- package/src/atp/index.js +0 -38
- package/src/atp/merchantAgent.js +0 -118
- package/src/atp/protocol.js +0 -41
- package/src/atp/questionComposer.js +0 -133
- package/src/atp/serviceHelper.js +0 -92
- package/src/canary.js +0 -13
- package/src/config.js +0 -351
- package/src/evolve/guards.js +0 -1
- package/src/evolve/pipeline/collect.js +0 -1
- package/src/evolve/pipeline/dispatch.js +0 -1
- package/src/evolve/pipeline/enrich.js +0 -1
- package/src/evolve/pipeline/hub.js +0 -1
- package/src/evolve/pipeline/select.js +0 -1
- package/src/evolve/pipeline/signals.js +0 -1
- package/src/evolve/utils.js +0 -1
- package/src/evolve.js +0 -1
- package/src/experiment/agentRunner.js +0 -229
- package/src/experiment/cli.js +0 -159
- package/src/experiment/comparison.js +0 -233
- package/src/experiment/metrics.js +0 -75
- package/src/forceUpdate.js +0 -991
- package/src/gep/a2a.js +0 -173
- package/src/gep/a2aProtocol.js +0 -1
- package/src/gep/analyzer.js +0 -35
- package/src/gep/antiAbuseTelemetry.js +0 -1
- package/src/gep/assetCallLog.js +0 -197
- package/src/gep/assetStore.js +0 -723
- package/src/gep/assets.js +0 -36
- package/src/gep/autoDistillConv.js +0 -1
- package/src/gep/autoDistillLlm.js +0 -1
- package/src/gep/bridge.js +0 -138
- package/src/gep/candidateEval.js +0 -1
- package/src/gep/candidates.js +0 -1
- package/src/gep/claimNudge.js +0 -121
- package/src/gep/cliContracts.js +0 -1190
- package/src/gep/contentHash.js +0 -1
- package/src/gep/conversationDistiller.js +0 -1
- package/src/gep/conversationSniffer.js +0 -1
- package/src/gep/crypto.js +0 -1
- package/src/gep/curriculum.js +0 -1
- package/src/gep/deviceId.js +0 -1
- package/src/gep/directoryClient.js +0 -115
- package/src/gep/envFingerprint.js +0 -1
- package/src/gep/epigenetics.js +0 -1
- package/src/gep/execBridge.js +0 -1
- package/src/gep/executionTrace.js +0 -291
- package/src/gep/explore.js +0 -1
- package/src/gep/featureFlags.js +0 -121
- package/src/gep/gitOps.js +0 -265
- package/src/gep/hash.js +0 -1
- package/src/gep/hostErrorClassifier.js +0 -34
- package/src/gep/hubFetch.js +0 -1
- package/src/gep/hubReview.js +0 -1
- package/src/gep/hubSearch.js +0 -1
- package/src/gep/hubVerify.js +0 -1
- package/src/gep/idleScheduler.js +0 -400
- package/src/gep/issueReporter.js +0 -416
- package/src/gep/learningSignals.js +0 -1
- package/src/gep/llmReview.js +0 -92
- package/src/gep/localStateAwareness.js +0 -243
- package/src/gep/mailboxTransport.js +0 -119
- package/src/gep/memoryGraph.js +0 -1
- package/src/gep/memoryGraphAdapter.js +0 -1
- package/src/gep/mutation.js +0 -1
- package/src/gep/narrativeMemory.js +0 -1
- package/src/gep/oauthLogin.js +0 -181
- package/src/gep/openPRRegistry.js +0 -1
- package/src/gep/paths.js +0 -522
- package/src/gep/personality.js +0 -1
- package/src/gep/policyCheck.js +0 -1
- package/src/gep/portable.js +0 -103
- package/src/gep/privacyClient.js +0 -235
- package/src/gep/prompt.js +0 -1
- package/src/gep/questionGenerator.js +0 -518
- package/src/gep/recallInject.js +0 -1
- package/src/gep/recallVerifier.js +0 -1
- package/src/gep/reflection.js +0 -1
- package/src/gep/sanitize.js +0 -264
- package/src/gep/savingsCore.js +0 -1
- package/src/gep/schemas/capsule.js +0 -170
- package/src/gep/schemas/gene.js +0 -154
- package/src/gep/schemas/index.js +0 -8
- package/src/gep/schemas/protocol.js +0 -51
- package/src/gep/schemas/task.js +0 -74
- package/src/gep/selector.js +0 -1
- package/src/gep/selfPR.js +0 -469
- package/src/gep/signals.js +0 -776
- package/src/gep/skill2gep.js +0 -1056
- package/src/gep/skill2gepAudit.js +0 -303
- package/src/gep/skill2recipes.js +0 -511
- package/src/gep/skillDistiller.js +0 -1
- package/src/gep/skillPublisher.js +0 -358
- package/src/gep/solidify.js +0 -1
- package/src/gep/strategy.js +0 -1
- package/src/gep/taskReceiver.js +0 -575
- package/src/gep/tokenSavings.js +0 -1
- package/src/gep/trajectoryExport.js +0 -1
- package/src/gep/validationReport.js +0 -55
- package/src/gep/validator/index.js +0 -411
- package/src/gep/validator/reporter.js +0 -210
- package/src/gep/validator/sandboxExecutor.js +0 -480
- package/src/gep/validator/stakeBootstrap.js +0 -357
- package/src/gep/workspaceKeychain.js +0 -1
- package/src/ops/cleanup.js +0 -80
- package/src/ops/commentary.js +0 -60
- package/src/ops/health_check.js +0 -104
- package/src/ops/index.js +0 -11
- package/src/ops/innovation.js +0 -67
- package/src/ops/lifecycle.js +0 -798
- package/src/ops/self_repair.js +0 -76
- package/src/ops/skills_monitor.js +0 -147
- package/src/ops/trigger.js +0 -33
- package/src/proxy/clientSettings.js +0 -405
- package/src/proxy/envelope.js +0 -59
- package/src/proxy/extensions/dmHandler.js +0 -45
- package/src/proxy/extensions/sessionHandler.js +0 -141
- package/src/proxy/extensions/skillUpdater.js +0 -64
- package/src/proxy/extensions/traceControl.js +0 -1
- package/src/proxy/index.js +0 -1395
- package/src/proxy/inject.js +0 -1
- package/src/proxy/lifecycle/manager.js +0 -1568
- package/src/proxy/mailbox/state.js +0 -207
- package/src/proxy/mailbox/store.js +0 -590
- package/src/proxy/router/cache_passthrough.js +0 -26
- package/src/proxy/router/features.js +0 -84
- package/src/proxy/router/gemini_route.js +0 -154
- package/src/proxy/router/messages_route.js +0 -535
- package/src/proxy/router/model_router.js +0 -113
- package/src/proxy/router/models_route.js +0 -52
- package/src/proxy/router/ollama_route.js +0 -103
- package/src/proxy/router/responses_route.js +0 -170
- package/src/proxy/router/vertex_route.js +0 -110
- package/src/proxy/server/http.js +0 -363
- package/src/proxy/server/routes.js +0 -558
- package/src/proxy/server/settings.js +0 -115
- package/src/proxy/sync/engine.js +0 -179
- package/src/proxy/sync/inbound.js +0 -211
- package/src/proxy/sync/outbound.js +0 -320
- package/src/proxy/task/monitor.js +0 -131
- package/src/proxy/trace/extractor.js +0 -1
- package/src/proxy/trace/usage.js +0 -1
- package/src/webui/client/clientJs/assets.js +0 -111
- package/src/webui/client/clientJs/bootstrap.js +0 -92
- package/src/webui/client/clientJs/common.js +0 -77
- package/src/webui/client/clientJs/i18n.js +0 -366
- package/src/webui/client/clientJs/index.js +0 -35
- package/src/webui/client/clientJs/interactions.js +0 -351
- package/src/webui/client/clientJs/overview.js +0 -152
- package/src/webui/client/clientJs/personality.js +0 -285
- package/src/webui/client/clientJs/pipelines.js +0 -330
- package/src/webui/client/indexHtml.js +0 -221
- package/src/webui/client/static.js +0 -23
- package/src/webui/client/stylesCss.js +0 -639
- package/src/webui/client/vendor/README.md +0 -15
- package/src/webui/client/vendor/echarts.min.js +0 -45
- package/src/webui/index.js +0 -14
- package/src/webui/observer/assets.js +0 -146
- package/src/webui/observer/index.js +0 -37
- package/src/webui/observer/interactions.js +0 -127
- package/src/webui/observer/jsonl.js +0 -75
- package/src/webui/observer/paths.js +0 -46
- package/src/webui/observer/personality.js +0 -43
- package/src/webui/observer/pipelineEvents.js +0 -58
- package/src/webui/observer/redact.js +0 -63
- package/src/webui/observer/runs.js +0 -356
- package/src/webui/observer/safety.js +0 -57
- package/src/webui/observer/skills.js +0 -70
- package/src/webui/observer/status.js +0 -71
- package/src/webui/server/http.js +0 -138
- package/src/webui/server/routes.js +0 -41
package/index.js
CHANGED
|
@@ -1,3586 +1 @@
|
|
|
1
|
-
|
|
2
|
-
function _parseBootstrapSemver(version) {
|
|
3
|
-
const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(version || ''));
|
|
4
|
-
if (!match) return null;
|
|
5
|
-
return {
|
|
6
|
-
major: match[1],
|
|
7
|
-
minor: match[2],
|
|
8
|
-
patch: match[3],
|
|
9
|
-
prerelease: match[4] ? match[4].split('.') : [],
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function _compareBootstrapNumeric(left, right) {
|
|
14
|
-
if (left.length !== right.length) return left.length - right.length;
|
|
15
|
-
if (left < right) return -1;
|
|
16
|
-
if (left > right) return 1;
|
|
17
|
-
return 0;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function _compareBootstrapPrerelease(left, right) {
|
|
21
|
-
const leftNumeric = /^\d+$/.test(left);
|
|
22
|
-
const rightNumeric = /^\d+$/.test(right);
|
|
23
|
-
if (leftNumeric && rightNumeric) return _compareBootstrapNumeric(left, right);
|
|
24
|
-
if (leftNumeric) return -1;
|
|
25
|
-
if (rightNumeric) return 1;
|
|
26
|
-
if (left < right) return -1;
|
|
27
|
-
if (left > right) return 1;
|
|
28
|
-
return 0;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function _compareBootstrapSemver(left, right) {
|
|
32
|
-
const a = _parseBootstrapSemver(left);
|
|
33
|
-
const b = _parseBootstrapSemver(right);
|
|
34
|
-
if (!a || !b) return null;
|
|
35
|
-
for (const key of ['major', 'minor', 'patch']) {
|
|
36
|
-
const cmp = _compareBootstrapNumeric(a[key], b[key]);
|
|
37
|
-
if (cmp !== 0) return cmp;
|
|
38
|
-
}
|
|
39
|
-
if (!a.prerelease.length && !b.prerelease.length) return 0;
|
|
40
|
-
if (!a.prerelease.length) return 1;
|
|
41
|
-
if (!b.prerelease.length) return -1;
|
|
42
|
-
const max = Math.max(a.prerelease.length, b.prerelease.length);
|
|
43
|
-
for (let i = 0; i < max; i++) {
|
|
44
|
-
if (a.prerelease[i] === undefined) return -1;
|
|
45
|
-
if (b.prerelease[i] === undefined) return 1;
|
|
46
|
-
const cmp = _compareBootstrapPrerelease(a.prerelease[i], b.prerelease[i]);
|
|
47
|
-
if (cmp !== 0) return cmp;
|
|
48
|
-
}
|
|
49
|
-
return 0;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function _bootstrapVersionSatisfies(currentVersion, requiredVersion) {
|
|
53
|
-
if (String(currentVersion || '') === String(requiredVersion || '')) return true;
|
|
54
|
-
const cmp = _compareBootstrapSemver(currentVersion, requiredVersion);
|
|
55
|
-
return cmp !== null && cmp >= 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function _failClosedForceUpdateBootstrap(backupName, entryName, error) {
|
|
59
|
-
const detail = error && error.message ? error.message : String(error || 'unknown error');
|
|
60
|
-
console.error('[ForceUpdate] Bootstrap recovery failed for ' + backupName +
|
|
61
|
-
(entryName ? ' while restoring ' + entryName : '') + ': ' + detail);
|
|
62
|
-
console.error('[ForceUpdate] Refusing to continue startup; recovery backup and journal were left in place.');
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function _recoverInterruptedForceUpdateBootstrap() {
|
|
67
|
-
const fs = require('fs');
|
|
68
|
-
const path = require('path');
|
|
69
|
-
const installRoot = __dirname;
|
|
70
|
-
const backupPrefix = '.evolver-force-update-backup-';
|
|
71
|
-
const journalName = '.evolver-force-update-journal.json';
|
|
72
|
-
let backups = [];
|
|
73
|
-
try {
|
|
74
|
-
backups = fs.readdirSync(installRoot)
|
|
75
|
-
.filter((name) => name.startsWith(backupPrefix))
|
|
76
|
-
.sort()
|
|
77
|
-
.reverse();
|
|
78
|
-
} catch (_) {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
for (const backupName of backups) {
|
|
82
|
-
const backupRoot = path.join(installRoot, backupName);
|
|
83
|
-
const journalPath = path.join(backupRoot, journalName);
|
|
84
|
-
let journal = null;
|
|
85
|
-
try {
|
|
86
|
-
journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
|
87
|
-
} catch (_) {
|
|
88
|
-
// Not a force-update recovery journal; leave unrelated directories alone.
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (!journal || journal.state !== 'precommit' || !journal.requiredVersion) continue;
|
|
92
|
-
let currentVersion = '';
|
|
93
|
-
try {
|
|
94
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(installRoot, 'package.json'), 'utf8'));
|
|
95
|
-
currentVersion = pkg && pkg.version ? String(pkg.version) : '';
|
|
96
|
-
} catch (_) {
|
|
97
|
-
// If the package marker is unreadable, prefer restoring the old payload.
|
|
98
|
-
}
|
|
99
|
-
if (_bootstrapVersionSatisfies(currentVersion, String(journal.requiredVersion))) {
|
|
100
|
-
try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch (_) {
|
|
101
|
-
// Cleanup failure is non-fatal; normal startup can continue.
|
|
102
|
-
}
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
let entries = [];
|
|
106
|
-
try {
|
|
107
|
-
entries = fs.readdirSync(backupRoot, { withFileTypes: true });
|
|
108
|
-
} catch (readErr) {
|
|
109
|
-
_failClosedForceUpdateBootstrap(backupName, '', readErr);
|
|
110
|
-
}
|
|
111
|
-
for (const entry of entries) {
|
|
112
|
-
if (entry.name === journalName) continue;
|
|
113
|
-
const livePath = path.join(installRoot, entry.name);
|
|
114
|
-
const backupPath = path.join(backupRoot, entry.name);
|
|
115
|
-
try {
|
|
116
|
-
if (entry.name === 'index.js') {
|
|
117
|
-
const tmpPath = livePath + '.' + process.pid + '.recover-tmp';
|
|
118
|
-
try { fs.rmSync(tmpPath, { force: true }); } catch (_) {}
|
|
119
|
-
fs.copyFileSync(backupPath, tmpPath);
|
|
120
|
-
try {
|
|
121
|
-
const backupStat = fs.statSync(backupPath);
|
|
122
|
-
fs.chmodSync(tmpPath, backupStat.mode & 0o777);
|
|
123
|
-
} catch (_) {
|
|
124
|
-
// Recovery can proceed without mode restoration; npm/service launches
|
|
125
|
-
// normally use `node index.js` after an interrupted update.
|
|
126
|
-
}
|
|
127
|
-
fs.renameSync(tmpPath, livePath);
|
|
128
|
-
fs.rmSync(backupPath, { force: true });
|
|
129
|
-
} else {
|
|
130
|
-
fs.rmSync(livePath, { recursive: true, force: true });
|
|
131
|
-
fs.renameSync(backupPath, livePath);
|
|
132
|
-
}
|
|
133
|
-
} catch (restoreErr) {
|
|
134
|
-
_failClosedForceUpdateBootstrap(backupName, entry.name, restoreErr);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch (_) {
|
|
138
|
-
// Best-effort cleanup; recovery already restored the payload.
|
|
139
|
-
}
|
|
140
|
-
console.warn('[ForceUpdate] Recovered interrupted install from ' + backupName);
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
_recoverInterruptedForceUpdateBootstrap();
|
|
147
|
-
|
|
148
|
-
function _printProxyTokenUsage(out = process.stderr) {
|
|
149
|
-
out.write('Usage: node index.js proxy-token [--settings FILE]\n');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function _readProxyTokenFromSettingsFile(fs, settingsFile) {
|
|
153
|
-
try {
|
|
154
|
-
const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
155
|
-
return parsed && parsed.proxy && typeof parsed.proxy.token === 'string'
|
|
156
|
-
? parsed.proxy.token
|
|
157
|
-
: '';
|
|
158
|
-
} catch {
|
|
159
|
-
return '';
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// `proxy-token` is a credential helper for Codex. Handle it before loading any
|
|
164
|
-
// project .env so a workspace cannot change EVOLVER_SETTINGS_DIR or other local
|
|
165
|
-
// state used to find the proxy token.
|
|
166
|
-
if (process.argv[2] === 'proxy-token') {
|
|
167
|
-
try {
|
|
168
|
-
const _fs = require('fs');
|
|
169
|
-
const _os = require('os');
|
|
170
|
-
const _path = require('path');
|
|
171
|
-
let settingsFile = '';
|
|
172
|
-
for (let i = 3; i < process.argv.length; i++) {
|
|
173
|
-
const arg = process.argv[i];
|
|
174
|
-
if (arg === '-h' || arg === '--help') {
|
|
175
|
-
_printProxyTokenUsage(process.stdout);
|
|
176
|
-
process.exit(0);
|
|
177
|
-
}
|
|
178
|
-
if (arg === '--settings') {
|
|
179
|
-
if (!process.argv[i + 1]) {
|
|
180
|
-
_printProxyTokenUsage();
|
|
181
|
-
console.error('[proxy-token] missing value for --settings');
|
|
182
|
-
process.exit(2);
|
|
183
|
-
}
|
|
184
|
-
settingsFile = process.argv[i + 1];
|
|
185
|
-
i++;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
_printProxyTokenUsage();
|
|
189
|
-
console.error('[proxy-token] unknown argument');
|
|
190
|
-
process.exit(2);
|
|
191
|
-
}
|
|
192
|
-
const defaultSettingsFile = _path.join(
|
|
193
|
-
process.env.EVOLVER_SETTINGS_DIR || _path.join(_os.homedir(), '.evolver'),
|
|
194
|
-
'settings.json',
|
|
195
|
-
);
|
|
196
|
-
const token = _readProxyTokenFromSettingsFile(_fs, settingsFile || defaultSettingsFile);
|
|
197
|
-
if (!token) {
|
|
198
|
-
console.error('[proxy-token] no active proxy token found; start evolver with EVOMAP_PROXY=1 first');
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
process.stdout.write(token + '\n');
|
|
202
|
-
process.exit(0);
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.error('[proxy-token] Failed:', e && e.message || e);
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Load .env BEFORE any internal require so that a2aProtocol and ATP
|
|
210
|
-
// modules see A2A_NODE_SECRET / A2A_NODE_ID / A2A_HUB_URL at first
|
|
211
|
-
// access and never fall back to a stale persisted/cached secret.
|
|
212
|
-
// Reported in #460.
|
|
213
|
-
//
|
|
214
|
-
// Load order matters (see #526): we must not call getRepoRoot() before
|
|
215
|
-
// .env is loaded, otherwise EVOLVER_REPO_ROOT set in .env is silently
|
|
216
|
-
// ignored because getRepoRoot() caches the .git-walk result on first
|
|
217
|
-
// call. Strategy:
|
|
218
|
-
// 1. Try .env at process.cwd() first. This is where a user running
|
|
219
|
-
// `evolver` from their project root expects the file, and it is
|
|
220
|
-
// independent of getRepoRoot() caching.
|
|
221
|
-
// 2. Read EVOLVER_REPO_ROOT from process.env (dotenv just populated it
|
|
222
|
-
// if set in cwd/.env).
|
|
223
|
-
// 3. Only now call getRepoRoot(), which will honor EVOLVER_REPO_ROOT
|
|
224
|
-
// if present; then try .env at that root as well (dotenv never
|
|
225
|
-
// overwrites already-set keys, so step 1 wins when both exist).
|
|
226
|
-
try {
|
|
227
|
-
const _path = require('path');
|
|
228
|
-
// Step 1: load .env from process.cwd() before any internal require.
|
|
229
|
-
// Matches the regression test for #460 which asserts
|
|
230
|
-
// `require('dotenv').config` appears before any ./src/* require other
|
|
231
|
-
// than ./src/gep/paths.
|
|
232
|
-
require('dotenv').config({ path: _path.join(process.cwd(), '.env') });
|
|
233
|
-
// Suppress the "Using host git repository at" banner during bootstrap.
|
|
234
|
-
// If .env at the discovered root overrides EVOLVER_REPO_ROOT, the
|
|
235
|
-
// initial banner would point at the wrong path and mislead users
|
|
236
|
-
// debugging the very chicken-and-egg problem #526 reported. The banner
|
|
237
|
-
// prints for real when getRepoRoot() is called later by application code.
|
|
238
|
-
const _prevQuiet = process.env.EVOLVER_QUIET_PARENT_GIT;
|
|
239
|
-
process.env.EVOLVER_QUIET_PARENT_GIT = '1';
|
|
240
|
-
const { getRepoRoot: _getRepoRoot } = require('./src/gep/paths');
|
|
241
|
-
const _root = _getRepoRoot();
|
|
242
|
-
if (_root && _root !== process.cwd()) {
|
|
243
|
-
require('dotenv').config({ path: _path.join(_root, '.env') });
|
|
244
|
-
}
|
|
245
|
-
if (_prevQuiet === undefined) delete process.env.EVOLVER_QUIET_PARENT_GIT;
|
|
246
|
-
else process.env.EVOLVER_QUIET_PARENT_GIT = _prevQuiet;
|
|
247
|
-
} catch (e) { /* dotenv is optional */ }
|
|
248
|
-
|
|
249
|
-
const evolve = require('./src/evolve');
|
|
250
|
-
const { solidify } = require('./src/gep/solidify');
|
|
251
|
-
const path = require('path');
|
|
252
|
-
const os = require('os');
|
|
253
|
-
const { getRepoRoot } = require('./src/gep/paths');
|
|
254
|
-
const fs = require('fs');
|
|
255
|
-
const { spawn } = require('child_process');
|
|
256
|
-
|
|
257
|
-
// Interruptible sleep: SIGCONT (and any future wake hook) can short-circuit
|
|
258
|
-
// pending sleeps so a daemon that just woke from macOS sleep doesn't sit
|
|
259
|
-
// out the rest of its pre-sleep adaptive-sleep window on the resumed
|
|
260
|
-
// monotonic clock. Without this, the heartbeat side recovers via the
|
|
261
|
-
// drift detector but the outer evolve cycle stays paused up to maxSleepMs
|
|
262
|
-
// (default 5 min) after wake. Each call tracks its own resolver in
|
|
263
|
-
// _activeSleeps so the wake hook can resolve all of them.
|
|
264
|
-
const _activeSleeps = new Set();
|
|
265
|
-
function sleepMs(ms) {
|
|
266
|
-
const n = parseInt(String(ms), 10);
|
|
267
|
-
const t = Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
268
|
-
return new Promise(resolve => {
|
|
269
|
-
let done = false;
|
|
270
|
-
const finish = () => {
|
|
271
|
-
if (done) return;
|
|
272
|
-
done = true;
|
|
273
|
-
clearTimeout(timer);
|
|
274
|
-
_activeSleeps.delete(finish);
|
|
275
|
-
resolve();
|
|
276
|
-
};
|
|
277
|
-
const timer = setTimeout(finish, t);
|
|
278
|
-
// NOTE: intentionally NOT calling timer.unref() here. When the daemon is in
|
|
279
|
-
// a long adaptive sleep (up to maxSleepMs = 5 min by default), this timer is
|
|
280
|
-
// often the ONLY ref'd handle keeping the event loop alive. All other timers
|
|
281
|
-
// (_heartbeatTimer, _heartbeatDriftInterval, _selfDrivingPollTimer, etc.) are
|
|
282
|
-
// unref'd, so once the evolve loop's sleepMs timer was also unref'd, Node.js
|
|
283
|
-
// could see zero ref'd handles and silently exit the process mid-sleep. That
|
|
284
|
-
// was the root cause of "first launch ok, idle for a while, then evolver dead
|
|
285
|
-
// with no log trace" on macOS. A ref'd sleep timer is the load-bearing event-
|
|
286
|
-
// loop anchor during idle periods; it fires within maxSleepMs and the daemon
|
|
287
|
-
// then reschedules itself normally. Leaving it ref'd has no observable cost.
|
|
288
|
-
_activeSleeps.add(finish);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
function _interruptAllSleeps() {
|
|
292
|
-
if (_activeSleeps.size === 0) return;
|
|
293
|
-
// Snapshot first because resolvers mutate the set as they run.
|
|
294
|
-
const finishers = Array.from(_activeSleeps);
|
|
295
|
-
for (const fn of finishers) {
|
|
296
|
-
try { fn(); } catch (_) {}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Round-6 (§19.5): heartbeat-internal wake recovery (drainPool +
|
|
301
|
-
// pokeHeartbeat + SSE restart + self-driving-poll re-arm) lives in
|
|
302
|
-
// a2aProtocol so the drift detector can drive it directly. Process-
|
|
303
|
-
// level wake hooks (sleepMs interrupter, validator daemon poke) are
|
|
304
|
-
// registered with a2aProtocol so both the SIGCONT handler and the
|
|
305
|
-
// drift detector long-sleep branch run them. Lazy-register so requires
|
|
306
|
-
// resolve cleanly under test (single Set of registered hooks; cheap to
|
|
307
|
-
// re-register idempotently).
|
|
308
|
-
let _wakeHooksRegistered = false;
|
|
309
|
-
function _registerProcessWakeHooks() {
|
|
310
|
-
if (_wakeHooksRegistered) return;
|
|
311
|
-
try {
|
|
312
|
-
const a2a = require('./src/gep/a2aProtocol.js');
|
|
313
|
-
if (typeof a2a.registerWakeHook !== 'function') return;
|
|
314
|
-
a2a.registerWakeHook(function () {
|
|
315
|
-
try { _interruptAllSleeps(); } catch (_) {}
|
|
316
|
-
});
|
|
317
|
-
// R13: guards.sleepMs is a separate private helper used for 60-120s
|
|
318
|
-
// backoffs inside evolve.run() arms (active-sessions, system-load,
|
|
319
|
-
// pending-solidify). Without this hook, a guard sleep that spans
|
|
320
|
-
// macOS suspend would block the cycle for the full window on the
|
|
321
|
-
// resumed monotonic clock even though the outer sleep was interrupted.
|
|
322
|
-
a2a.registerWakeHook(function () {
|
|
323
|
-
try {
|
|
324
|
-
const guards = require('./src/evolve/guards');
|
|
325
|
-
if (guards && typeof guards._interruptGuardSleeps === 'function') {
|
|
326
|
-
guards._interruptGuardSleeps();
|
|
327
|
-
}
|
|
328
|
-
} catch (_) {}
|
|
329
|
-
});
|
|
330
|
-
a2a.registerWakeHook(function () {
|
|
331
|
-
try {
|
|
332
|
-
const v = require('./src/gep/validator');
|
|
333
|
-
if (v && typeof v.pokeValidatorDaemon === 'function') {
|
|
334
|
-
v.pokeValidatorDaemon();
|
|
335
|
-
}
|
|
336
|
-
} catch (_) {}
|
|
337
|
-
});
|
|
338
|
-
_wakeHooksRegistered = true;
|
|
339
|
-
} catch (_) {}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function readJsonSafe(p) {
|
|
343
|
-
try {
|
|
344
|
-
if (!fs.existsSync(p)) return null;
|
|
345
|
-
const raw = fs.readFileSync(p, 'utf8');
|
|
346
|
-
if (!raw.trim()) return null;
|
|
347
|
-
return JSON.parse(raw);
|
|
348
|
-
} catch (e) {
|
|
349
|
-
return null;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Mark a pending evolution run as rejected (state-only, no git rollback).
|
|
355
|
-
* @param {string} statePath - Path to evolution_solidify_state.json
|
|
356
|
-
* @returns {boolean} true if a pending run was found and rejected
|
|
357
|
-
*/
|
|
358
|
-
function rejectPendingRun(statePath) {
|
|
359
|
-
try {
|
|
360
|
-
const state = readJsonSafe(statePath);
|
|
361
|
-
if (state && state.last_run && state.last_run.run_id) {
|
|
362
|
-
state.last_solidify = {
|
|
363
|
-
run_id: state.last_run.run_id,
|
|
364
|
-
rejected: true,
|
|
365
|
-
reason: 'loop_bridge_disabled_autoreject_no_rollback',
|
|
366
|
-
timestamp: new Date().toISOString(),
|
|
367
|
-
};
|
|
368
|
-
const tmp = `${statePath}.tmp`;
|
|
369
|
-
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
370
|
-
fs.renameSync(tmp, statePath);
|
|
371
|
-
return true;
|
|
372
|
-
}
|
|
373
|
-
} catch (e) {
|
|
374
|
-
console.warn('[Loop] Failed to clear pending run state: ' + (e.message || e));
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return false;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function isPendingSolidify(state) {
|
|
381
|
-
const lastRun = state && state.last_run ? state.last_run : null;
|
|
382
|
-
const lastSolid = state && state.last_solidify ? state.last_solidify : null;
|
|
383
|
-
if (!lastRun || !lastRun.run_id) return false;
|
|
384
|
-
if (!lastSolid || !lastSolid.run_id) return true;
|
|
385
|
-
return String(lastSolid.run_id) !== String(lastRun.run_id);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function parseMs(v, fallback) {
|
|
389
|
-
const n = parseInt(String(v == null ? '' : v), 10);
|
|
390
|
-
if (Number.isFinite(n)) return Math.max(0, n);
|
|
391
|
-
return fallback;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function parseBoolEnv(v, fallback) {
|
|
395
|
-
if (v == null) return fallback;
|
|
396
|
-
const s = String(v).toLowerCase().trim();
|
|
397
|
-
if (s === '' ) return fallback;
|
|
398
|
-
if (s === 'false' || s === '0' || s === 'off' || s === 'no') return false;
|
|
399
|
-
if (s === 'true' || s === '1' || s === 'on' || s === 'yes') return true;
|
|
400
|
-
return fallback;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
class CycleTimeoutError extends Error {
|
|
404
|
-
constructor(timeoutMs, phase, cycleNum) {
|
|
405
|
-
super('Cycle hard-timeout exceeded after ' + timeoutMs + 'ms (cycle=' + cycleNum + ', phase=' + phase + ')');
|
|
406
|
-
this.name = 'CycleTimeoutError';
|
|
407
|
-
this.code = 'CYCLE_TIMEOUT';
|
|
408
|
-
this.timeoutMs = timeoutMs;
|
|
409
|
-
this.phase = phase;
|
|
410
|
-
this.cycleNum = cycleNum;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Issue #528: on Windows, child_process.spawn(detached: true, windowsHide: true)
|
|
415
|
-
// allocates a new conhost window every time -- windowsHide is silently ignored
|
|
416
|
-
// in detached mode. So suicide-respawn (cycles >= max, RSS over budget, or the
|
|
417
|
-
// new cycle hard-timeout) opens a new cmd popup on every restart. We now skip
|
|
418
|
-
// the in-process detached spawn on Windows by default and rely on an external
|
|
419
|
-
// supervisor (NSSM, pm2-windows, etc.) to respawn the daemon on non-zero exit.
|
|
420
|
-
// Users who insist can opt back in with EVOLVER_SUICIDE_WINDOWS=true (and accept
|
|
421
|
-
// the popups).
|
|
422
|
-
function spawnReplacementProcess({ reason, args, logPath }) {
|
|
423
|
-
const isWindows = process.platform === 'win32';
|
|
424
|
-
const allowOnWindows = parseBoolEnv(process.env.EVOLVER_SUICIDE_WINDOWS, false);
|
|
425
|
-
if (isWindows && !allowOnWindows) {
|
|
426
|
-
console.log(
|
|
427
|
-
'[Daemon] Skipping in-process respawn on Windows (' + reason + '). ' +
|
|
428
|
-
'Native Node spawn(detached, windowsHide) opens a cmd popup on every restart (Issue #528). ' +
|
|
429
|
-
'Set EVOLVER_SUICIDE_WINDOWS=true to opt back in. ' +
|
|
430
|
-
'Recommended: run evolver under an external supervisor (NSSM, pm2-windows, etc.) so it restarts on exit.'
|
|
431
|
-
);
|
|
432
|
-
return { spawned: false, reason: 'windows_default_skip' };
|
|
433
|
-
}
|
|
434
|
-
try {
|
|
435
|
-
const logFd = fs.openSync(logPath, 'a');
|
|
436
|
-
const spawnOpts = {
|
|
437
|
-
detached: true,
|
|
438
|
-
stdio: ['ignore', logFd, logFd],
|
|
439
|
-
env: process.env,
|
|
440
|
-
windowsHide: true,
|
|
441
|
-
};
|
|
442
|
-
const child = spawn(process.execPath, [__filename, ...args], spawnOpts);
|
|
443
|
-
child.unref();
|
|
444
|
-
return { spawned: true };
|
|
445
|
-
} catch (e) {
|
|
446
|
-
console.error('[Daemon] Spawn-replacement failed (' + reason + '): ' + (e && e.message || e));
|
|
447
|
-
return { spawned: false, reason: 'spawn_error', error: e };
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Atomic write of the cycle_progress.json file. Wrapper polls this file every
|
|
452
|
-
// 60s; if updated_at goes stale beyond EVOLVE_INNER_STUCK_TIMEOUT_SEC the
|
|
453
|
-
// wrapper treats the inner core as zombie and SIGKILLs it. See Issue #19 (the
|
|
454
|
-
// 22-day stuck-cycle incident) and the cross-repo timeout plan for context.
|
|
455
|
-
function writeCycleProgressAtomic(progressPath, fields) {
|
|
456
|
-
try {
|
|
457
|
-
const data = Object.assign({}, fields, { updated_at: Date.now() });
|
|
458
|
-
const tmp = progressPath + '.tmp.' + process.pid;
|
|
459
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
460
|
-
fs.renameSync(tmp, progressPath);
|
|
461
|
-
return true;
|
|
462
|
-
} catch (e) {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function handleCycleTimeout({ error, cycleProgressPath, progressFields, suicideEnabled, args, logPath, spawnReplacementFn }) {
|
|
468
|
-
const msg = error && error.message ? String(error.message) : String(error);
|
|
469
|
-
console.error('[Daemon] ' + msg);
|
|
470
|
-
|
|
471
|
-
if (!suicideEnabled) {
|
|
472
|
-
console.warn('[Daemon] Cycle hard-timeout treated as non-fatal because EVOLVER_SUICIDE=false.');
|
|
473
|
-
writeCycleProgressAtomic(cycleProgressPath, Object.assign({}, progressFields, {
|
|
474
|
-
phase: 'cycle_timeout_nonfatal',
|
|
475
|
-
}));
|
|
476
|
-
return { action: 'continue' };
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
writeCycleProgressAtomic(cycleProgressPath, Object.assign({}, progressFields, {
|
|
480
|
-
phase: 'cycle_timeout_respawn',
|
|
481
|
-
}));
|
|
482
|
-
spawnReplacementFn({
|
|
483
|
-
reason: 'cycle_hard_timeout',
|
|
484
|
-
args: args,
|
|
485
|
-
logPath: logPath,
|
|
486
|
-
});
|
|
487
|
-
return { action: 'respawn' };
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function logTimedOutEvolveRejection(error, logFn) {
|
|
491
|
-
const lateMsg = error && error.message ? String(error.message) : String(error);
|
|
492
|
-
try {
|
|
493
|
-
logFn('[Daemon] Timed-out evolve.run() eventually rejected: ' + lateMsg);
|
|
494
|
-
} catch (logError) {
|
|
495
|
-
const fallbackMsg = logError && logError.message ? String(logError.message) : String(logError);
|
|
496
|
-
console.error('[Daemon] Failed to log timed-out evolve.run() rejection: ' + fallbackMsg);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function observeTimedOutEvolvePromise(evolvePromise, logFn = console.error) {
|
|
501
|
-
if (!evolvePromise || typeof evolvePromise.then !== 'function') {
|
|
502
|
-
return { status: 'ignored' };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
Promise.resolve(evolvePromise).then(
|
|
506
|
-
function () {},
|
|
507
|
-
function (error) {
|
|
508
|
-
logTimedOutEvolveRejection(error, logFn);
|
|
509
|
-
}
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
return { status: 'observing' };
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async function waitForTimedOutEvolvePromise(evolvePromise, logFn = console.error) {
|
|
516
|
-
if (!evolvePromise || typeof evolvePromise.then !== 'function') {
|
|
517
|
-
return { status: 'ignored' };
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
await evolvePromise;
|
|
522
|
-
return { status: 'resolved' };
|
|
523
|
-
} catch (error) {
|
|
524
|
-
logTimedOutEvolveRejection(error, logFn);
|
|
525
|
-
return { status: 'rejected' };
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function getLastSignals(statePath) {
|
|
530
|
-
try {
|
|
531
|
-
const st = readJsonSafe(statePath);
|
|
532
|
-
return (st && st.last_run && Array.isArray(st.last_run.signals)) ? st.last_run.signals : [];
|
|
533
|
-
} catch (e) {
|
|
534
|
-
return [];
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Singleton Guard - prevent multiple evolver daemon instances.
|
|
539
|
-
//
|
|
540
|
-
// Lock location + lease tunables live in src/adapters/scripts/_lockPaths.js
|
|
541
|
-
// (issue #176): the session-start hook's auto-restart guard needs the exact
|
|
542
|
-
// same resolution, and inlining it in both places drifted. The Round-4
|
|
543
|
-
// (per-install-mode pidfile convergence) and Round-9 (lease staleness)
|
|
544
|
-
// history notes moved there with the code.
|
|
545
|
-
const {
|
|
546
|
-
getLockFilePath,
|
|
547
|
-
lockIsStaleByLease: _lockIsStaleByLease,
|
|
548
|
-
STALE_LOCK_TTL_MS,
|
|
549
|
-
LOCK_REFRESH_MS,
|
|
550
|
-
} = require('./src/adapters/scripts/_lockPaths');
|
|
551
|
-
|
|
552
|
-
function _writeLockAtomic(lockFile, payload) {
|
|
553
|
-
// Round-6 (§19.8): the previous implementation used tmp + rename, which
|
|
554
|
-
// makes the WRITE atomic but not the OWNERSHIP claim. Two processes
|
|
555
|
-
// could both rename their own tmp file over the same lockFile (rename
|
|
556
|
-
// is atomic per call but successive renames overwrite each other), then
|
|
557
|
-
// each read it back and -- if the second rename happened between the
|
|
558
|
-
// first process's rename and its read-back -- see the OTHER process's
|
|
559
|
-
// PID. Each then concludes "I lost the race" and exits, leaving the
|
|
560
|
-
// lockFile owned by no live process. Symmetrically, two processes can
|
|
561
|
-
// each see their own PID if the reads happen between their respective
|
|
562
|
-
// renames, and both conclude they won.
|
|
563
|
-
//
|
|
564
|
-
// The proper primitive is link(2): given a unique tmp file, link to the
|
|
565
|
-
// target path fails atomically with EEXIST if the target already
|
|
566
|
-
// exists. Only one of N concurrent linkers succeeds.
|
|
567
|
-
// NOTE(windows): mode 0o700 / 0o600 are silently ignored on Windows.
|
|
568
|
-
// The lock directory and tmp file will NOT be owner-only on Windows.
|
|
569
|
-
// Isolation relies solely on the user-profile directory ACLs.
|
|
570
|
-
const dir = path.dirname(lockFile);
|
|
571
|
-
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (_) {}
|
|
572
|
-
const tmp = lockFile + '.' + process.pid + '.tmp';
|
|
573
|
-
fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
574
|
-
// link() requires the target NOT to exist. The caller in the takeover
|
|
575
|
-
// path has already unlinked the stale lockFile via fs.unlinkSync
|
|
576
|
-
// (ignoring ENOENT). If a concurrent process beat us to the link, our
|
|
577
|
-
// linkSync below throws EEXIST -- we surface that to the caller and
|
|
578
|
-
// clean up our tmp.
|
|
579
|
-
//
|
|
580
|
-
// EXDEV: fs.link() fails with EXDEV when tmp and lockFile are on different
|
|
581
|
-
// volumes (can happen on Windows when EVOLVER_LOCK_DIR points to a drive
|
|
582
|
-
// other than the tmp dir). Fall back to renameSync, which Node.js handles
|
|
583
|
-
// cross-device by copying + deleting. rename is not atomic in this path,
|
|
584
|
-
// so the EEXIST guard is lost, but this is an unusual configuration and
|
|
585
|
-
// the result is still safe (worst case: two daemons both think they won,
|
|
586
|
-
// the second write wins, the first will exit on its next tick when it
|
|
587
|
-
// reads back a foreign PID via the heartbeat).
|
|
588
|
-
try {
|
|
589
|
-
fs.linkSync(tmp, lockFile);
|
|
590
|
-
} catch (err) {
|
|
591
|
-
if (err && err.code === 'EXDEV') {
|
|
592
|
-
// Cross-device: rename falls back to copy+delete inside Node.js; this
|
|
593
|
-
// loses the atomic-EEXIST guarantee but is better than hard-failing.
|
|
594
|
-
try {
|
|
595
|
-
fs.renameSync(tmp, lockFile);
|
|
596
|
-
} catch (renameErr) {
|
|
597
|
-
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
598
|
-
throw renameErr;
|
|
599
|
-
}
|
|
600
|
-
return; // tmp has been consumed by renameSync, skip unlinkSync below
|
|
601
|
-
}
|
|
602
|
-
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
603
|
-
throw err;
|
|
604
|
-
}
|
|
605
|
-
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function _readLockPayload(lockFile) {
|
|
609
|
-
try {
|
|
610
|
-
const raw = fs.readFileSync(lockFile, 'utf8').trim();
|
|
611
|
-
if (!raw) return null;
|
|
612
|
-
// Backward-compat: older lock files contained only the pid as text.
|
|
613
|
-
// Newer payloads are JSON {pid, uid, startedAt}.
|
|
614
|
-
if (raw[0] === '{') {
|
|
615
|
-
try { return JSON.parse(raw); } catch (_) { return null; }
|
|
616
|
-
}
|
|
617
|
-
const pid = parseInt(raw, 10);
|
|
618
|
-
return Number.isFinite(pid) && pid > 0 ? { pid: pid } : null;
|
|
619
|
-
} catch (_) { return null; }
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function _lockPayload() {
|
|
623
|
-
return JSON.stringify({
|
|
624
|
-
pid: process.pid,
|
|
625
|
-
uid: typeof process.getuid === 'function' ? process.getuid() : null,
|
|
626
|
-
startedAt: new Date().toISOString(),
|
|
627
|
-
// Round-9: marks a daemon that refreshes this lock file's mtime on a
|
|
628
|
-
// lease (see startLockRefresh). Only when this flag is present do
|
|
629
|
-
// acquireLock / refuseHelloIfDaemonRunning trust mtime-staleness to
|
|
630
|
-
// reclaim a lock whose PID is alive -- the PID-reuse / SIGKILL-stale
|
|
631
|
-
// guard. A lock written by an OLDER daemon (no flag) keeps the legacy
|
|
632
|
-
// kill(0)-only behavior so a new binary can never falsely steal a
|
|
633
|
-
// still-running old daemon's lock (which would run two daemons).
|
|
634
|
-
lease: true,
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// STALE_LOCK_TTL_MS / LOCK_REFRESH_MS / _lockIsStaleByLease come from
|
|
639
|
-
// src/adapters/scripts/_lockPaths.js (required next to getLockFilePath
|
|
640
|
-
// above) — see issue #176 and the Round-9 history note in that module.
|
|
641
|
-
let _lockRefreshTimer = null;
|
|
642
|
-
|
|
643
|
-
// Start refreshing the lock file's mtime so other processes can tell this
|
|
644
|
-
// daemon is alive without trusting a (recyclable) PID. unref'd: it never
|
|
645
|
-
// keeps the event loop open on its own, but fires for as long as the daemon
|
|
646
|
-
// is otherwise alive.
|
|
647
|
-
function startLockRefresh() {
|
|
648
|
-
if (_lockRefreshTimer) return;
|
|
649
|
-
const lockFile = getLockFilePath();
|
|
650
|
-
_lockRefreshTimer = setInterval(function () {
|
|
651
|
-
try {
|
|
652
|
-
const now = new Date();
|
|
653
|
-
fs.utimesSync(lockFile, now, now);
|
|
654
|
-
} catch (_) { /* lock gone / FS error: nothing we can do here */ }
|
|
655
|
-
}, LOCK_REFRESH_MS);
|
|
656
|
-
if (_lockRefreshTimer && typeof _lockRefreshTimer.unref === 'function') {
|
|
657
|
-
_lockRefreshTimer.unref();
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function stopLockRefresh() {
|
|
662
|
-
if (_lockRefreshTimer) {
|
|
663
|
-
clearInterval(_lockRefreshTimer);
|
|
664
|
-
_lockRefreshTimer = null;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function acquireLock() {
|
|
669
|
-
const lockFile = getLockFilePath();
|
|
670
|
-
// NOTE(windows): mode 0o700 / 0o600 are silently ignored on Windows.
|
|
671
|
-
// Lock directory and file permissions provide no OS-level isolation on
|
|
672
|
-
// Windows; rely on user-profile directory ACLs (%USERPROFILE%\.evomap).
|
|
673
|
-
try {
|
|
674
|
-
try { fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 }); } catch (_) {}
|
|
675
|
-
try {
|
|
676
|
-
fs.writeFileSync(lockFile, _lockPayload(), { flag: 'wx', mode: 0o600 });
|
|
677
|
-
return true;
|
|
678
|
-
} catch (exclErr) {
|
|
679
|
-
if (exclErr.code !== 'EEXIST') throw exclErr;
|
|
680
|
-
}
|
|
681
|
-
const payload = _readLockPayload(lockFile);
|
|
682
|
-
if (!payload || !Number.isFinite(payload.pid) || payload.pid <= 0) {
|
|
683
|
-
console.log('[Singleton] Corrupt lock file. Taking over.');
|
|
684
|
-
} else if (_lockIsStaleByLease(lockFile, payload)) {
|
|
685
|
-
// Round-9: a lease-aware daemon has not refreshed this lock's mtime
|
|
686
|
-
// within the stale TTL. Either it was SIGKILLed/crashed, or its PID
|
|
687
|
-
// has since been reused by an unrelated process (kill(0) below would
|
|
688
|
-
// then falsely report it alive and we would refuse to start forever).
|
|
689
|
-
// The expired lease is authoritative: take over.
|
|
690
|
-
console.log('[Singleton] Lock lease expired (PID ' + payload.pid + ', no mtime refresh for > ' +
|
|
691
|
-
Math.round(STALE_LOCK_TTL_MS / 60_000) + 'min). Taking over.');
|
|
692
|
-
} else {
|
|
693
|
-
try {
|
|
694
|
-
process.kill(payload.pid, 0);
|
|
695
|
-
// Process exists. Distinguish "alive, our user" (refuse) from
|
|
696
|
-
// "alive, different uid" (also refuse -- never barge into a root
|
|
697
|
-
// daemon under a user-launched evolver, etc.).
|
|
698
|
-
console.log(`[Singleton] Evolver loop already running (PID ${payload.pid}). Exiting.`);
|
|
699
|
-
return false;
|
|
700
|
-
} catch (e) {
|
|
701
|
-
if (e && e.code === 'EPERM') {
|
|
702
|
-
// PID exists but belongs to another user. Conservatively
|
|
703
|
-
// refuse: barging in would race the existing daemon for
|
|
704
|
-
// secret/heartbeat ownership.
|
|
705
|
-
console.warn(`[Singleton] Lock owned by PID ${payload.pid} (different user). Refusing to take over. ` +
|
|
706
|
-
`Remove ${lockFile} manually if the PID is actually dead.`);
|
|
707
|
-
return false;
|
|
708
|
-
}
|
|
709
|
-
console.log(`[Singleton] Stale lock found (PID ${payload.pid}). Taking over.`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
// Atomic takeover so two daemons that both observe the same stale PID
|
|
713
|
-
// and pass the kill(0) check cannot both end up "owning" the lock.
|
|
714
|
-
//
|
|
715
|
-
// Bug it fixes: the previous "unconditional unlinkSync then linkSync"
|
|
716
|
-
// pattern was NOT atomic across acquirers. Interleaving where P1 wins
|
|
717
|
-
// the linkSync but P2's unlinkSync then deletes P1's freshly-linked
|
|
718
|
-
// file (P2 never re-verifies it's deleting the same stale lock it
|
|
719
|
-
// observed) lets P2's subsequent linkSync also succeed. Both processes
|
|
720
|
-
// then return true and start a daemon, racing each other on the
|
|
721
|
-
// shared singleton secret store.
|
|
722
|
-
//
|
|
723
|
-
// renameSync is atomic at the filesystem level: only one of N racing
|
|
724
|
-
// acquirers can move the stale lockFile to a unique claim name, the
|
|
725
|
-
// rest see ENOENT and abort. After the claim succeeds, _writeLockAtomic
|
|
726
|
-
// installs the fresh lock; the claim file is unlinked in every exit
|
|
727
|
-
// path so it doesn't accumulate.
|
|
728
|
-
const claimFile = lockFile + '.' + process.pid + '.' + Date.now() + '.takeover';
|
|
729
|
-
try {
|
|
730
|
-
fs.renameSync(lockFile, claimFile);
|
|
731
|
-
} catch (e) {
|
|
732
|
-
if (e && e.code === 'ENOENT') {
|
|
733
|
-
// Another concurrent acquirer already claimed the stale lock.
|
|
734
|
-
// They'll race us on _writeLockAtomic below; the EEXIST branch
|
|
735
|
-
// handles the loser case correctly.
|
|
736
|
-
} else {
|
|
737
|
-
console.warn('[Singleton] Cannot claim stale lock at ' + lockFile + ': ' + e.message);
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
try {
|
|
742
|
-
_writeLockAtomic(lockFile, _lockPayload());
|
|
743
|
-
} catch (linkErr) {
|
|
744
|
-
try { fs.unlinkSync(claimFile); } catch (_) {}
|
|
745
|
-
if (linkErr && linkErr.code === 'EEXIST') {
|
|
746
|
-
// Lost the link race to another concurrent acquirer. Read who
|
|
747
|
-
// won (best-effort) for the log line.
|
|
748
|
-
const winner = _readLockPayload(lockFile);
|
|
749
|
-
console.log('[Singleton] Lost takeover race to PID ' + (winner && winner.pid) + '. Exiting.');
|
|
750
|
-
return false;
|
|
751
|
-
}
|
|
752
|
-
throw linkErr;
|
|
753
|
-
}
|
|
754
|
-
try { fs.unlinkSync(claimFile); } catch (_) {}
|
|
755
|
-
return true;
|
|
756
|
-
} catch (err) {
|
|
757
|
-
console.error('[Singleton] Lock acquisition failed:', err);
|
|
758
|
-
return false;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function releaseLock() {
|
|
763
|
-
const lockFile = getLockFilePath();
|
|
764
|
-
try {
|
|
765
|
-
if (fs.existsSync(lockFile)) {
|
|
766
|
-
const payload = _readLockPayload(lockFile);
|
|
767
|
-
if (payload && payload.pid === process.pid) fs.unlinkSync(lockFile);
|
|
768
|
-
}
|
|
769
|
-
} catch (e) { /* ignore */ }
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Round-7 (§20.7): the daemon-lock acquireLock() only fires for `--loop`
|
|
773
|
-
// mode; CLI subcommands like `evolver fetch` and `evolver sync` run
|
|
774
|
-
// without acquiring the lock and freely call sendHelloToHub when
|
|
775
|
-
// node_secret is missing. The hub-side hello-with-rotate rewrites the
|
|
776
|
-
// node_secret on disk, so two writers (the daemon's heartbeat path
|
|
777
|
-
// rotating one secret + this CLI's sendHelloToHub writing a different
|
|
778
|
-
// one) race to be "last writer." Whichever wrote second silences the
|
|
779
|
-
// other -- the daemon then 401-loops -> enters reauth backoff -> goes
|
|
780
|
-
// silent for 30 min..4 h. The original §6 "instance lock" scenario.
|
|
781
|
-
//
|
|
782
|
-
// This helper does NOT take the lock (the daemon legitimately owns it);
|
|
783
|
-
// it only refuses to proceed if a LIVE daemon owns the lock AND we are
|
|
784
|
-
// about to send a fresh hello. If the daemon is alive it already has a
|
|
785
|
-
// valid secret in ~/.evomap/node_secret, so the right thing for the CLI
|
|
786
|
-
// is to wait briefly for the secret to appear (newly registered daemon)
|
|
787
|
-
// or exit with an actionable error.
|
|
788
|
-
//
|
|
789
|
-
// Callers: every CLI subcommand whose runner could call sendHelloToHub()
|
|
790
|
-
// when getHubNodeSecret() returns empty. Currently: fetch, sync
|
|
791
|
-
// (round-7 §20.7), plus atp-complete, buy, orders, verify (round-8
|
|
792
|
-
// §21.8 -- the ATP runners hit the same vector via consumerAgent /
|
|
793
|
-
// merchantAgent / atpExecute paths).
|
|
794
|
-
function refuseHelloIfDaemonRunning(toolLabel) {
|
|
795
|
-
try {
|
|
796
|
-
const lockFile = getLockFilePath();
|
|
797
|
-
if (!fs.existsSync(lockFile)) return; // no daemon
|
|
798
|
-
const payload = _readLockPayload(lockFile);
|
|
799
|
-
if (!payload || !Number.isFinite(payload.pid) || payload.pid <= 0) return;
|
|
800
|
-
if (payload.pid === process.pid) return; // shouldn't happen for CLI
|
|
801
|
-
// Round-9: a lease-aware lock whose mtime has gone stale means the
|
|
802
|
-
// daemon is dead (or its PID was reused). Do NOT refuse on it -- that
|
|
803
|
-
// was the "CLI hard-exits because it trusts a recyclable PID" hole.
|
|
804
|
-
if (_lockIsStaleByLease(lockFile, payload)) return;
|
|
805
|
-
try {
|
|
806
|
-
process.kill(payload.pid, 0);
|
|
807
|
-
} catch (e) {
|
|
808
|
-
if (e && e.code === 'ESRCH') return; // stale lock, daemon is gone
|
|
809
|
-
// EPERM = alive under a different user; still a real daemon. Fall
|
|
810
|
-
// through to refuse.
|
|
811
|
-
}
|
|
812
|
-
console.error(
|
|
813
|
-
'[' + toolLabel + '] Refusing to send hello: an evolver daemon ' +
|
|
814
|
-
'(PID ' + payload.pid + ') is running and owns ~/.evomap/instance.lock.'
|
|
815
|
-
);
|
|
816
|
-
console.error(
|
|
817
|
-
' Two concurrent hello calls would rotate node_secret against ' +
|
|
818
|
-
'each other and silence the daemon for hours.'
|
|
819
|
-
);
|
|
820
|
-
console.error(
|
|
821
|
-
' Either wait for the daemon to register (the secret will ' +
|
|
822
|
-
'appear at ~/.evomap/node_secret), or stop the daemon and retry.'
|
|
823
|
-
);
|
|
824
|
-
process.exit(1);
|
|
825
|
-
} catch (_) {
|
|
826
|
-
// Never let the lock-check helper itself escape; if the helper
|
|
827
|
-
// throws (FS permission, etc.) we fall through to the original code
|
|
828
|
-
// path. The race we're protecting against is rare; failing closed
|
|
829
|
-
// here would block legitimate CLI use.
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
async function main() {
|
|
834
|
-
const args = process.argv.slice(2);
|
|
835
|
-
const command = args[0];
|
|
836
|
-
const isLoop = args.includes('--loop') || args.includes('--mad-dog');
|
|
837
|
-
const isVerbose = args.includes('--verbose') || args.includes('-v') ||
|
|
838
|
-
String(process.env.EVOLVER_VERBOSE || '').toLowerCase() === 'true';
|
|
839
|
-
if (isVerbose) process.env.EVOLVER_VERBOSE = 'true';
|
|
840
|
-
|
|
841
|
-
if (!command || command === 'run' || command === '/evolve' || isLoop) {
|
|
842
|
-
if (isLoop) {
|
|
843
|
-
// EPIPE protection. The daemon may outlive the controlling
|
|
844
|
-
// terminal (user closes the iTerm tab, ssh session drops, parent
|
|
845
|
-
// shell exits). The SIGHUP handler below covers the signal side,
|
|
846
|
-
// but the underlying pty fd is gone and the FIRST subsequent
|
|
847
|
-
// console.log writes to a closed pipe -> stdout emits 'error'
|
|
848
|
-
// with EPIPE. Without a listener attached, Node escalates EPIPE
|
|
849
|
-
// to uncaughtException, which our handler then turns into
|
|
850
|
-
// process.exit(1). Net result: daemon silently dies the next
|
|
851
|
-
// time it tries to log, with no useful trace. Swallow EPIPE
|
|
852
|
-
// explicitly so the daemon stays alive when its terminal goes
|
|
853
|
-
// away (matching standard daemonization practice).
|
|
854
|
-
try {
|
|
855
|
-
// EPIPE: swallow (daemon must outlive its controlling terminal).
|
|
856
|
-
// Non-EPIPE (EIO, ENOSPC on redirected log, etc.): the listener
|
|
857
|
-
// already prevents 'error' from escalating to uncaughtException,
|
|
858
|
-
// so write a one-line trace to the *other* stream so operators
|
|
859
|
-
// can see the failure mode instead of finding a silent daemon.
|
|
860
|
-
process.stdout.on('error', function (err) {
|
|
861
|
-
if (err && err.code === 'EPIPE') return;
|
|
862
|
-
try { process.stderr.write('[evolver] stdout error: ' + (err && (err.code || err.message) || err) + '\n'); } catch (_) {}
|
|
863
|
-
});
|
|
864
|
-
process.stderr.on('error', function (err) {
|
|
865
|
-
if (err && err.code === 'EPIPE') return;
|
|
866
|
-
try { process.stdout.write('[evolver] stderr error: ' + (err && (err.code || err.message) || err) + '\n'); } catch (_) {}
|
|
867
|
-
});
|
|
868
|
-
} catch (_) {}
|
|
869
|
-
|
|
870
|
-
const originalLog = console.log;
|
|
871
|
-
const originalWarn = console.warn;
|
|
872
|
-
const originalError = console.error;
|
|
873
|
-
function ts() { return '[' + new Date().toISOString() + ']'; }
|
|
874
|
-
// Wrap originals in try/catch so a broken transport (closed pty,
|
|
875
|
-
// disk full on a redirected log file) cannot escape and trip
|
|
876
|
-
// unhandledException -> exit(1) the next time we log.
|
|
877
|
-
console.log = (...args) => {
|
|
878
|
-
try { originalLog.call(console, ts(), ...args); } catch (_) {}
|
|
879
|
-
};
|
|
880
|
-
console.warn = (...args) => {
|
|
881
|
-
try { originalWarn.call(console, ts(), ...args); } catch (_) {}
|
|
882
|
-
};
|
|
883
|
-
console.error = (...args) => {
|
|
884
|
-
try { originalError.call(console, ts(), ...args); } catch (_) {}
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
console.log('Starting evolver...');
|
|
889
|
-
|
|
890
|
-
// Preflight: fail fast if git is not on PATH. On Windows in particular
|
|
891
|
-
// a missing git binary can cause evolver to hang silently (see #394),
|
|
892
|
-
// because several cycle-critical steps shell out to git early (repo
|
|
893
|
-
// resolution, diff, blast-radius). Catching this up front makes the
|
|
894
|
-
// failure mode obvious.
|
|
895
|
-
try {
|
|
896
|
-
const { execSync } = require('child_process');
|
|
897
|
-
execSync('git --version', { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
|
898
|
-
} catch (_gitErr) {
|
|
899
|
-
console.error('');
|
|
900
|
-
console.error('[Preflight] Could not run "git --version". Evolver requires git to be installed and available on PATH.');
|
|
901
|
-
console.error('[Preflight] On Windows: install Git from https://git-scm.com/download/win and make sure `git --version` works in a fresh terminal.');
|
|
902
|
-
console.error('[Preflight] On macOS: xcode-select --install (or `brew install git`)');
|
|
903
|
-
console.error('[Preflight] On Linux: sudo apt-get install -y git (or your distro equivalent)');
|
|
904
|
-
console.error('');
|
|
905
|
-
process.exit(1);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
if (isLoop) {
|
|
909
|
-
// Internal daemon loop (no wrapper required).
|
|
910
|
-
if (!acquireLock()) process.exit(0);
|
|
911
|
-
// Round-9: refresh the lock lease so other processes can detect a
|
|
912
|
-
// crash / PID reuse via stale mtime instead of trusting kill(0).
|
|
913
|
-
startLockRefresh();
|
|
914
|
-
|
|
915
|
-
// Linux OOM score adjustment: lower oom_score_adj so the kernel
|
|
916
|
-
// deprioritises evolver when choosing an OOM victim. This is a
|
|
917
|
-
// best-effort hint -- the kernel can still kill us under extreme
|
|
918
|
-
// memory pressure, but we will not be the first target.
|
|
919
|
-
//
|
|
920
|
-
// Value -500 (range -1000..1000; -1000 = never kill, 0 = default,
|
|
921
|
-
// +1000 = kill first). -500 gives meaningful protection without
|
|
922
|
-
// reserving the slot for truly critical system services.
|
|
923
|
-
//
|
|
924
|
-
// Requires the process to be either root or to have CAP_SYS_RESOURCE.
|
|
925
|
-
// On most Docker/k8s images running as non-root this write will fail
|
|
926
|
-
// with EACCES -- that is expected and harmless; we log a one-liner so
|
|
927
|
-
// operators know to pass --oom-score-adj=-500 via their container spec,
|
|
928
|
-
// or to set /proc/<pid>/oom_score_adj from the supervising process.
|
|
929
|
-
//
|
|
930
|
-
// Users who want to set this from outside the process (safer, no CAP):
|
|
931
|
-
// echo -500 > /proc/$(pgrep -f "node.*evolver.*--loop")/oom_score_adj
|
|
932
|
-
//
|
|
933
|
-
// Opt-out: EVOLVER_DISABLE_OOM_ADJUST=1
|
|
934
|
-
if (process.platform === 'linux' &&
|
|
935
|
-
String(process.env.EVOLVER_DISABLE_OOM_ADJUST || '') !== '1') {
|
|
936
|
-
try {
|
|
937
|
-
const _oomPath = '/proc/self/oom_score_adj';
|
|
938
|
-
const _oomTarget = '-500';
|
|
939
|
-
require('fs').writeFileSync(_oomPath, _oomTarget + '\n', 'utf8');
|
|
940
|
-
console.log('[evolver] Set Linux oom_score_adj=' + _oomTarget +
|
|
941
|
-
' to reduce OOM-kill priority.');
|
|
942
|
-
} catch (oomErr) {
|
|
943
|
-
// EACCES under non-root / no CAP_SYS_RESOURCE is expected; EPERM
|
|
944
|
-
// inside stricter seccomp/apparmor profiles. Both are non-fatal.
|
|
945
|
-
const oomCode = oomErr && oomErr.code ? oomErr.code : 'unknown';
|
|
946
|
-
console.log('[evolver] Could not set oom_score_adj (' + oomCode +
|
|
947
|
-
'). To protect evolver from OOM kill, run as root, add ' +
|
|
948
|
-
'CAP_SYS_RESOURCE, or set oom_score_adj externally via your ' +
|
|
949
|
-
'container spec (e.g. resources.requests + oom_score_adj in k8s).');
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// Round-4: macOS App Nap / QoS demotion mitigation. Without this,
|
|
954
|
-
// a backgrounded `evolver --loop` running in an iTerm tab gets its
|
|
955
|
-
// process QoS demoted to UTILITY/BACKGROUND once the parent app
|
|
956
|
-
// is no longer focused. CPU runtime caps to ~5% of one core,
|
|
957
|
-
// setTimeout resolution drops toward 1 Hz, disk I/O is throttled.
|
|
958
|
-
// The drift detector cannot rescue this because the demotion does
|
|
959
|
-
// NOT cause Date.now() to jump -- only the inter-tick interval
|
|
960
|
-
// dilates, which the detector samples through its own (also
|
|
961
|
-
// demoted) setInterval. Net result: heartbeat appears alive but
|
|
962
|
-
// ticks fire so slowly that the hub marks the node offline,
|
|
963
|
-
// matching the user-reported "first launch ok -> idle -> dead
|
|
964
|
-
// forever" pattern.
|
|
965
|
-
//
|
|
966
|
-
// os.setPriority() raises BSD process priority; macOS bridges that
|
|
967
|
-
// to Mach thread QoS via the priority bridge so the demotion does
|
|
968
|
-
// not engage. -10 is the most negative value raisable without
|
|
969
|
-
// root. Failures are logged but non-fatal (e.g. EPERM under a
|
|
970
|
-
// restrictive sandbox -- the daemon continues, just unprotected).
|
|
971
|
-
// Opt-out via EVOLVER_DISABLE_PRIORITY_BOOST=1 for users on
|
|
972
|
-
// power-constrained battery profiles who would rather accept
|
|
973
|
-
// the throttle than the extra wake-time.
|
|
974
|
-
if (process.platform === 'darwin' &&
|
|
975
|
-
String(process.env.EVOLVER_DISABLE_PRIORITY_BOOST || '') !== '1') {
|
|
976
|
-
let priorityBoostOk = false;
|
|
977
|
-
try {
|
|
978
|
-
const os = require('os');
|
|
979
|
-
os.setPriority(0, -10);
|
|
980
|
-
// Round-5: actually verify the boost landed. macOS silently
|
|
981
|
-
// returns success from setPriority(2) under some sandboxes
|
|
982
|
-
// even when the underlying syscall was rejected by the
|
|
983
|
-
// Mach thread-policy bridge. Read it back; if the value is
|
|
984
|
-
// still 0 (or worse), App Nap will engage and the user
|
|
985
|
-
// sees the "first launch -> idle -> dead" symptom from
|
|
986
|
-
// round-3 with NO log evidence to RCA from.
|
|
987
|
-
const observed = os.getPriority();
|
|
988
|
-
if (observed <= -10) {
|
|
989
|
-
priorityBoostOk = true;
|
|
990
|
-
console.log('[evolver] Raised process priority on macOS to ' + observed +
|
|
991
|
-
' to prevent App Nap / QoS demotion.');
|
|
992
|
-
} else {
|
|
993
|
-
console.warn('[evolver] setPriority(-10) reported success but observed priority is ' +
|
|
994
|
-
observed + '; App Nap protection NOT in effect. ' +
|
|
995
|
-
'Run with EVOLVER_CAFFEINATE=1 or via `caffeinate -is node index.js --loop`.');
|
|
996
|
-
}
|
|
997
|
-
} catch (e) {
|
|
998
|
-
console.warn('[evolver] setPriority(-10) refused (' + (e && e.code || 'unknown') +
|
|
999
|
-
'): ' + (e && e.message || e) + '. App Nap protection NOT in effect. ' +
|
|
1000
|
-
'Run with EVOLVER_CAFFEINATE=1 or via `caffeinate -is node index.js --loop`.');
|
|
1001
|
-
}
|
|
1002
|
-
// Round-5: caffeinate side-child. Round-4 made this opt-in via
|
|
1003
|
-
// EVOLVER_CAFFEINATE=1 to avoid the extra Activity-Monitor row;
|
|
1004
|
-
// the round-5 audit found that 99% of users never set the env
|
|
1005
|
-
// var, so the App Nap fallback was effectively unused. Promote
|
|
1006
|
-
// to default-on when the priority boost did NOT land (so we
|
|
1007
|
-
// either have priority or have caffeinate, never neither),
|
|
1008
|
-
// unless the user has explicitly opted out via
|
|
1009
|
-
// EVOLVER_CAFFEINATE=0. The combined effect: a fresh laptop
|
|
1010
|
-
// user gets at least one layer of throttle protection without
|
|
1011
|
-
// having to learn about either env var.
|
|
1012
|
-
const caffeinateRaw = String(process.env.EVOLVER_CAFFEINATE || '').toLowerCase().trim();
|
|
1013
|
-
const caffeinateOptedIn = caffeinateRaw === '1' || caffeinateRaw === 'true';
|
|
1014
|
-
const caffeinateOptedOut = caffeinateRaw === '0' || caffeinateRaw === 'false';
|
|
1015
|
-
const caffeinateFallback = !priorityBoostOk && !caffeinateOptedOut;
|
|
1016
|
-
if (caffeinateOptedIn || caffeinateFallback) {
|
|
1017
|
-
try {
|
|
1018
|
-
const child = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
|
|
1019
|
-
detached: true,
|
|
1020
|
-
stdio: 'ignore',
|
|
1021
|
-
});
|
|
1022
|
-
child.unref();
|
|
1023
|
-
console.log('[evolver] Spawned caffeinate -i -w ' + process.pid +
|
|
1024
|
-
' to block App Nap (pid ' + child.pid + ').' +
|
|
1025
|
-
(caffeinateFallback ? ' (fallback because priority boost was refused)' : ''));
|
|
1026
|
-
} catch (e) {
|
|
1027
|
-
console.warn('[evolver] caffeinate spawn failed: ' +
|
|
1028
|
-
(e && e.message || e) + '. App Nap may throttle the heartbeat. ' +
|
|
1029
|
-
'Install caffeinate (Xcode CLT) or run under a launchd plist with NSAppSleepDisabled=1.');
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// Event-loop keep-alive anchor (defense-in-depth for the sleepMs fix).
|
|
1035
|
-
//
|
|
1036
|
-
// All timers in a2aProtocol.js (heartbeat, drift detector, self-driving
|
|
1037
|
-
// poll, SSE reconnect) are unref'd so they never prevent a clean exit.
|
|
1038
|
-
// The sleepMs() timer above is now ref'd (the primary fix), but as an
|
|
1039
|
-
// additional safety net we install one ref'd setInterval here that fires
|
|
1040
|
-
// every 10 minutes. Its only job is to emit a lightweight log line so
|
|
1041
|
-
// the evolver_loop.log gets touched even when the daemon is completely
|
|
1042
|
-
// idle (no session signals, evolve cycle sleeping at maxSleepMs). This
|
|
1043
|
-
// guarantees the event loop has at least one ref'd handle at all times
|
|
1044
|
-
// while the daemon is running, and provides a heartbeat-on-disk so
|
|
1045
|
-
// lifecycle.checkHealth() (MAX_SILENCE_MS = 30 min default) does not
|
|
1046
|
-
// wrongly declare the process stagnant during legitimate long idle windows.
|
|
1047
|
-
// Cleared in shutdown() so it does not outlive the daemon.
|
|
1048
|
-
const _KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000;
|
|
1049
|
-
let _keepAliveTimer = setInterval(function () {
|
|
1050
|
-
try {
|
|
1051
|
-
// Inline append that mirrors a2aProtocol._appendHeartbeatLog's
|
|
1052
|
-
// ENOENT-retry (that helper is not exported).
|
|
1053
|
-
const a2aKA = require('./src/gep/a2aProtocol');
|
|
1054
|
-
if (typeof a2aKA.getHeartbeatStats === 'function') {
|
|
1055
|
-
const s = a2aKA.getHeartbeatStats();
|
|
1056
|
-
const { getEvolverLogPath } = require('./src/gep/paths');
|
|
1057
|
-
const fsKA = require('fs');
|
|
1058
|
-
const pathKA = require('path');
|
|
1059
|
-
try {
|
|
1060
|
-
const logPath = getEvolverLogPath();
|
|
1061
|
-
fsKA.mkdirSync(pathKA.dirname(logPath), { recursive: true });
|
|
1062
|
-
const line = JSON.stringify({
|
|
1063
|
-
ts: new Date().toISOString(),
|
|
1064
|
-
type: 'keepalive_tick',
|
|
1065
|
-
hb_running: s.running,
|
|
1066
|
-
hb_last_tick_ago_s: s.lastTickAt ? Math.round((Date.now() - s.lastTickAt) / 1000) : null,
|
|
1067
|
-
}) + '\n';
|
|
1068
|
-
try {
|
|
1069
|
-
fsKA.appendFileSync(logPath, line, { encoding: 'utf8' });
|
|
1070
|
-
} catch (e) {
|
|
1071
|
-
if (e && e.code === 'ENOENT') {
|
|
1072
|
-
try {
|
|
1073
|
-
fsKA.mkdirSync(pathKA.dirname(logPath), { recursive: true });
|
|
1074
|
-
fsKA.appendFileSync(logPath, line, { encoding: 'utf8' });
|
|
1075
|
-
} catch (_) { /* log destination broken; do not throw out */ }
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
} catch (_) { /* never let the log write kill the timer */ }
|
|
1079
|
-
}
|
|
1080
|
-
} catch (_) { /* never let any error kill the keep-alive timer */ }
|
|
1081
|
-
}, _KEEPALIVE_INTERVAL_MS);
|
|
1082
|
-
// Intentionally ref'd: this is the explicit event-loop anchor.
|
|
1083
|
-
// Do NOT add .unref() here -- that would defeat the purpose.
|
|
1084
|
-
|
|
1085
|
-
function shutdown() {
|
|
1086
|
-
if (_keepAliveTimer) { clearInterval(_keepAliveTimer); _keepAliveTimer = null; }
|
|
1087
|
-
stopLockRefresh();
|
|
1088
|
-
releaseLock();
|
|
1089
|
-
// stopHeartbeat() clears the drift detector interval and the heartbeat
|
|
1090
|
-
// timer, preventing "ghost tick" log noise after exit and ensuring a
|
|
1091
|
-
// clean state if the process is somehow continued (test harness, etc.).
|
|
1092
|
-
try { require('./src/gep/a2aProtocol').stopHeartbeat(); } catch (e) {}
|
|
1093
|
-
try { require('./src/gep/a2aProtocol').stopEventStream(); } catch (e) {}
|
|
1094
|
-
}
|
|
1095
|
-
process.on('exit', shutdown);
|
|
1096
|
-
process.on('SIGINT', () => { shutdown(); process.exit(); });
|
|
1097
|
-
process.on('SIGTERM', () => { shutdown(); process.exit(); });
|
|
1098
|
-
// SIGHUP: two meanings depending on platform and how the daemon was started.
|
|
1099
|
-
//
|
|
1100
|
-
// macOS / interactive terminal: closing the iTerm/Terminal tab sends
|
|
1101
|
-
// SIGHUP to the controlling process, and Node's default action is to
|
|
1102
|
-
// terminate. That is the most common "first-launch, then idle, then
|
|
1103
|
-
// evolver dead" path on macOS. As a daemon we intentionally ignore it.
|
|
1104
|
-
//
|
|
1105
|
-
// Linux systemd: `systemctl reload evolver` delivers SIGHUP to signal
|
|
1106
|
-
// configuration reload. The socket / connection state may be stale (e.g.
|
|
1107
|
-
// the hub URL changed in .env, or the admin wants a fresh hello after a
|
|
1108
|
-
// manual secret rotation). We treat reload as a soft wake-recovery: drain
|
|
1109
|
-
// the undici pool, poke the heartbeat, and restart the SSE stream, which
|
|
1110
|
-
// is identical to what SIGCONT / the drift detector do on system resume.
|
|
1111
|
-
// We also emit sd_notify RELOADING=1 / READY=1 so systemd can track the
|
|
1112
|
-
// reload state (required for Type=notify units that call systemctl reload).
|
|
1113
|
-
//
|
|
1114
|
-
// A one-shot (non --loop) invocation keeps the default behavior because
|
|
1115
|
-
// this branch is gated on `isLoop`.
|
|
1116
|
-
process.on('SIGHUP', () => {
|
|
1117
|
-
try {
|
|
1118
|
-
if (process.platform === 'linux') {
|
|
1119
|
-
// On Linux, SIGHUP from systemd means reload, not terminal close.
|
|
1120
|
-
// Announce reload state to the service manager first so systemd
|
|
1121
|
-
// does not time out waiting, then perform the recovery, then signal
|
|
1122
|
-
// READY=1 again to confirm we are back in steady state.
|
|
1123
|
-
try {
|
|
1124
|
-
const a2aForSd = require('./src/gep/a2aProtocol.js');
|
|
1125
|
-
if (typeof a2aForSd._sdNotify === 'function') {
|
|
1126
|
-
// MONOTONIC_USEC requires microseconds from the monotonic clock.
|
|
1127
|
-
// process.hrtime() returns [sec, nsec] from a fixed epoch;
|
|
1128
|
-
// avoids BigInt literals for Node <10.3 compatibility.
|
|
1129
|
-
const _hrt = process.hrtime();
|
|
1130
|
-
const _monUsec = _hrt[0] * 1000000 + Math.floor(_hrt[1] / 1000);
|
|
1131
|
-
a2aForSd._sdNotify('RELOADING=1\nMONOTONIC_USEC=' + _monUsec);
|
|
1132
|
-
}
|
|
1133
|
-
} catch (_) {}
|
|
1134
|
-
console.warn('[evolver] Received SIGHUP on Linux (systemctl reload?). ' +
|
|
1135
|
-
'Running wake recovery (drain pool + poke heartbeat + restart SSE). ' +
|
|
1136
|
-
'To stop the daemon use SIGINT/SIGTERM.');
|
|
1137
|
-
try {
|
|
1138
|
-
const a2a = require('./src/gep/a2aProtocol.js');
|
|
1139
|
-
if (typeof a2a._runWakeRecovery === 'function') a2a._runWakeRecovery();
|
|
1140
|
-
} catch (_) {}
|
|
1141
|
-
// Interrupt any pending sleepMs so the evolve loop picks up
|
|
1142
|
-
// immediately after the reload rather than sitting out its window.
|
|
1143
|
-
try { _interruptAllSleeps(); } catch (_) {}
|
|
1144
|
-
// Signal READY=1 to close the RELOADING window. systemd will mark
|
|
1145
|
-
// the reload complete once it sees this notification.
|
|
1146
|
-
try {
|
|
1147
|
-
const a2aForSd2 = require('./src/gep/a2aProtocol.js');
|
|
1148
|
-
if (typeof a2aForSd2._sdNotify === 'function') {
|
|
1149
|
-
a2aForSd2._sdNotify('READY=1');
|
|
1150
|
-
}
|
|
1151
|
-
} catch (_) {}
|
|
1152
|
-
} else {
|
|
1153
|
-
// macOS / non-systemd: terminal-close semantics, ignore the signal.
|
|
1154
|
-
console.warn('[evolver] Received SIGHUP (controlling terminal closed?). ' +
|
|
1155
|
-
'Daemon ignoring -- heartbeat loop continues. To stop the daemon use SIGINT/SIGTERM.');
|
|
1156
|
-
}
|
|
1157
|
-
} catch (_) {}
|
|
1158
|
-
});
|
|
1159
|
-
// SIGCONT fires on `kill -CONT`, debugger detach, and some VM/sleep
|
|
1160
|
-
// resume paths. Nudge the heartbeat loop so it doesn't sit waiting for
|
|
1161
|
-
// its next scheduled tick (which could be up to 30 min away under
|
|
1162
|
-
// backoff) before reconnecting after a wake event. Also restart the
|
|
1163
|
-
// SSE stream: the underlying TCP socket almost certainly died during
|
|
1164
|
-
// the SIGSTOP window without a FIN reaching us, and the existing
|
|
1165
|
-
// exponential reconnect could be up to 120s away on the resumed
|
|
1166
|
-
// monotonic clock.
|
|
1167
|
-
// Round-6 (§19.5): register process-level wake hooks so both the
|
|
1168
|
-
// SIGCONT handler and the drift detector's long-sleep branch
|
|
1169
|
-
// (a2aProtocol) interrupt the outer evolve sleepMs and poke the
|
|
1170
|
-
// validator daemon, not just the heartbeat-internal recovery.
|
|
1171
|
-
_registerProcessWakeHooks();
|
|
1172
|
-
// SIGCONT is not supported on Windows (process.on() throws ERR_UNKNOWN_SIGNAL).
|
|
1173
|
-
// Wake recovery on Windows is handled exclusively by the drift detector.
|
|
1174
|
-
if (process.platform !== 'win32') {
|
|
1175
|
-
process.on('SIGCONT', () => {
|
|
1176
|
-
// Real recovery delegates to a2aProtocol._runWakeRecovery so
|
|
1177
|
-
// SIGCONT and the drift detector share one code path. NOTE:
|
|
1178
|
-
// per followups §18.2, SIGCONT is never sent by the macOS
|
|
1179
|
-
// kernel on system wake; this handler primarily covers:
|
|
1180
|
-
// - hypervisor/docker resume (container unpause)
|
|
1181
|
-
// - `kill -CONT <pid>` from operators or supervisors
|
|
1182
|
-
// - Linux debugger attach/detach (ptrace SIGSTOP+SIGCONT;
|
|
1183
|
-
// on Linux this is a true job-control signal unlike macOS)
|
|
1184
|
-
// - `docker unpause` (sends SIGCONT to all cgroup processes)
|
|
1185
|
-
// Bare-metal macOS wake recovery is driven by the drift
|
|
1186
|
-
// detector only. _runWakeRecovery() has a 1s debounce gate so
|
|
1187
|
-
// a rapid burst (e.g. gdb repeatedly attaching) collapses into
|
|
1188
|
-
// one recovery without leaking undici agents or SSE connections.
|
|
1189
|
-
try {
|
|
1190
|
-
const a2a = require('./src/gep/a2aProtocol.js');
|
|
1191
|
-
if (typeof a2a._runWakeRecovery === 'function') a2a._runWakeRecovery();
|
|
1192
|
-
} catch (_) {}
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
process.on('uncaughtException', (err) => {
|
|
1196
|
-
console.error('[FATAL] Uncaught exception:', err && err.stack ? err.stack : String(err));
|
|
1197
|
-
releaseLock();
|
|
1198
|
-
process.exit(1);
|
|
1199
|
-
});
|
|
1200
|
-
// Sliding window: only exit if many rejections cluster in a short
|
|
1201
|
-
// period AND the daemon shows no other signs of life. A daemon
|
|
1202
|
-
// running for weeks can accumulate harmless, unrelated rejections
|
|
1203
|
-
// (transient network blips, hub timeouts); the original cumulative
|
|
1204
|
-
// counter would eventually kill the process for noise. Cluster =
|
|
1205
|
-
// real failure cascade. But macOS wake bursts also synthesize
|
|
1206
|
-
// clusters: heartbeat / SSE / validator / merchantAgent / ATP all
|
|
1207
|
-
// fire near-simultaneously on resume and any subsystem with an
|
|
1208
|
-
// unhandled async-callback throw can blow past 5 rejections in
|
|
1209
|
-
// seconds. We add a liveness gate so an actively-recovering
|
|
1210
|
-
// daemon doesn't kill itself in the middle of a wake-recovery
|
|
1211
|
-
// storm. Threshold and window widened to match the macOS-wake
|
|
1212
|
-
// amplification observed in round-2 testing.
|
|
1213
|
-
const REJECTION_WINDOW_MS = 5 * 60 * 1000;
|
|
1214
|
-
const REJECTION_THRESHOLD = 10;
|
|
1215
|
-
const RECENT_LIVENESS_MS = 60 * 1000;
|
|
1216
|
-
let _rejectionTimestamps = [];
|
|
1217
|
-
function _heartbeatLooksAlive() {
|
|
1218
|
-
// Round-6 (§19.8): the previous implementation reached into
|
|
1219
|
-
// the `_testing` namespace and returned false (= "treat as
|
|
1220
|
-
// dead, exit on cluster") if that test-only accessor was
|
|
1221
|
-
// unavailable. Under bundling / minification / a future
|
|
1222
|
-
// refactor that drops the `_testing` export, this turned a
|
|
1223
|
-
// recovery storm into a guaranteed exit -- the OPPOSITE of
|
|
1224
|
-
// what the gate exists to do. Switched to the public
|
|
1225
|
-
// getHeartbeatStats() API (which surfaces `running` and
|
|
1226
|
-
// `lastTickAt` for exactly this purpose) and made the
|
|
1227
|
-
// require failure path "fail open" -- assume alive so we
|
|
1228
|
-
// don't kill an actively-recovering daemon just because the
|
|
1229
|
-
// module load failed on this turn.
|
|
1230
|
-
//
|
|
1231
|
-
// Round-10: `running` + recent `lastTickAt` alone are not
|
|
1232
|
-
// enough to claim "alive." `lastTickAt` is stamped at the
|
|
1233
|
-
// TOP of every heartbeat tick, regardless of whether the
|
|
1234
|
-
// tick actually makes progress -- including ticks that
|
|
1235
|
-
// immediately bail out because the loop is spinning in a
|
|
1236
|
-
// reauth backoff window (see a2aProtocol.js getHeartbeatStats
|
|
1237
|
-
// comment near :2940, which acknowledges that the loop
|
|
1238
|
-
// showed `running: true, lastTickAt: <recent>` even when
|
|
1239
|
-
// silent for 30 min waiting on a reauth backoff). In that
|
|
1240
|
-
// state a rejection cascade originating OUTSIDE the
|
|
1241
|
-
// heartbeat would be repeatedly forgiven while the loop is
|
|
1242
|
-
// not actually making forward progress. Require additionally
|
|
1243
|
-
// that `consecutiveFailures === 0` and that we are not
|
|
1244
|
-
// currently inside a reauth backoff window, so "alive" means
|
|
1245
|
-
// "making progress," not just "ticking."
|
|
1246
|
-
//
|
|
1247
|
-
// Trade-off: a transient hub blip that bumps
|
|
1248
|
-
// `consecutiveFailures` to 1 will now NOT forgive a
|
|
1249
|
-
// concurrent rejection cascade. That is intentional --
|
|
1250
|
-
// cascade-forgiveness exists to avoid flapping during a
|
|
1251
|
-
// healthy loop; during an unhealthy loop we should not keep
|
|
1252
|
-
// absorbing rejections silently.
|
|
1253
|
-
try {
|
|
1254
|
-
const a2a = require('./src/gep/a2aProtocol.js');
|
|
1255
|
-
if (!a2a || typeof a2a.getHeartbeatStats !== 'function') {
|
|
1256
|
-
// Cannot read state -- fail open. A real wedged daemon
|
|
1257
|
-
// will be caught by the next rejection if/when stats
|
|
1258
|
-
// become available, or by other watchdogs.
|
|
1259
|
-
return true;
|
|
1260
|
-
}
|
|
1261
|
-
const s = a2a.getHeartbeatStats();
|
|
1262
|
-
if (!s || !s.running) return false;
|
|
1263
|
-
const last = s.lastTickAt || 0;
|
|
1264
|
-
if (!(last > 0 && (Date.now() - last) < RECENT_LIVENESS_MS)) return false;
|
|
1265
|
-
// Round-10: gate on success state, not just tick freshness.
|
|
1266
|
-
if ((s.consecutiveFailures || 0) > 0) return false;
|
|
1267
|
-
if ((s.reauthBackoffUntil || 0) > Date.now()) return false;
|
|
1268
|
-
return true;
|
|
1269
|
-
} catch (_) {
|
|
1270
|
-
// Module load threw -- fail open for the same reason as
|
|
1271
|
-
// above. A genuinely broken require would surface via
|
|
1272
|
-
// uncaughtException long before this gate matters.
|
|
1273
|
-
return true;
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
process.on('unhandledRejection', (reason) => {
|
|
1277
|
-
const now = Date.now();
|
|
1278
|
-
_rejectionTimestamps.push(now);
|
|
1279
|
-
_rejectionTimestamps = _rejectionTimestamps.filter(function (t) {
|
|
1280
|
-
return now - t < REJECTION_WINDOW_MS;
|
|
1281
|
-
});
|
|
1282
|
-
console.error('[FATAL] Unhandled promise rejection (' + _rejectionTimestamps.length + ' in window):', reason && reason.stack ? reason.stack : String(reason));
|
|
1283
|
-
if (_rejectionTimestamps.length >= REJECTION_THRESHOLD) {
|
|
1284
|
-
if (_heartbeatLooksAlive()) {
|
|
1285
|
-
console.warn('[FATAL] ' + _rejectionTimestamps.length + ' rejections within ' +
|
|
1286
|
-
(REJECTION_WINDOW_MS / 1000) + 's BUT heartbeat ticked in the last ' +
|
|
1287
|
-
(RECENT_LIVENESS_MS / 1000) + 's. Treating as recovery storm, not exiting. ' +
|
|
1288
|
-
'Resetting rejection window so a real subsequent cascade can still trip the trap.');
|
|
1289
|
-
_rejectionTimestamps = [];
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
console.error('[FATAL] ' + _rejectionTimestamps.length + ' unhandled rejections within ' + (REJECTION_WINDOW_MS / 1000) + 's and no recent heartbeat activity. Exiting to avoid corrupt state.');
|
|
1293
|
-
releaseLock();
|
|
1294
|
-
process.exit(1);
|
|
1295
|
-
}
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
|
-
process.env.EVOLVE_LOOP = 'true';
|
|
1299
|
-
// Issue #96: from v1.85.0, --loop defaults EVOLVE_BRIDGE=true so the
|
|
1300
|
-
// daemon actually evolves the working tree. The previous default of
|
|
1301
|
-
// 'false' caused 33 days of empty cycling on Aurora — every cycle
|
|
1302
|
-
// hit rejectPendingRun(reason=loop_bridge_disabled_autoreject_no_rollback)
|
|
1303
|
-
// and produced no EvolutionEvent. Failed cycles still recover safely
|
|
1304
|
-
// via rollbackTracked (src/gep/gitOps.js#rollbackTracked, mode=stash
|
|
1305
|
-
// by default since v1.81.0): the daemon's changes get pushed to a
|
|
1306
|
-
// stash entry the user can recover with `git stash pop`.
|
|
1307
|
-
// Set EVOLVE_BRIDGE=false explicitly to opt back into observe-only.
|
|
1308
|
-
if (!process.env.EVOLVE_BRIDGE) {
|
|
1309
|
-
process.env.EVOLVE_BRIDGE = 'true';
|
|
1310
|
-
}
|
|
1311
|
-
const bridgeEnabled = String(process.env.EVOLVE_BRIDGE).toLowerCase() !== 'false';
|
|
1312
|
-
console.log(`Loop mode enabled (internal daemon, bridge=${process.env.EVOLVE_BRIDGE}, verbose=${isVerbose}).`);
|
|
1313
|
-
if (bridgeEnabled) {
|
|
1314
|
-
console.warn('[Daemon] EVOLVE_BRIDGE=true (default since v1.85.0).');
|
|
1315
|
-
console.warn('[Daemon] evolver may modify your working tree.');
|
|
1316
|
-
console.warn('[Daemon] Failed cycles auto-stash via "git stash push --include-untracked".');
|
|
1317
|
-
console.warn('[Daemon] Recover: git stash list | grep evolver-rollback');
|
|
1318
|
-
console.warn('[Daemon] Set EVOLVE_BRIDGE=false to opt out (observe-only mode).');
|
|
1319
|
-
} else {
|
|
1320
|
-
console.warn('[Daemon] EVOLVE_BRIDGE=false: evolver will NOT modify your working tree (observe-only).');
|
|
1321
|
-
console.warn('[Daemon] To enable real evolution: unset EVOLVE_BRIDGE or set it to "true".');
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Startup diagnostic: in daemon mode evolver consumes its own stdout
|
|
1325
|
-
// instead of handing `sessions_spawn(...)` directives to a host
|
|
1326
|
-
// runtime (OpenClaw). If the operator expects real-time agent assist
|
|
1327
|
-
// they are likely using the wrong mode; if they intend daemon mode
|
|
1328
|
-
// they still need AGENT_NAME / AGENT_SESSIONS_DIR pointing at a live
|
|
1329
|
-
// agent or the loop will just cycle on its own logs. Emit a single
|
|
1330
|
-
// warning at startup so "empty cycling" has a visible breadcrumb.
|
|
1331
|
-
try {
|
|
1332
|
-
const { diagnoseSessionSourceEmpty } = require('./src/evolve');
|
|
1333
|
-
const diag = diagnoseSessionSourceEmpty();
|
|
1334
|
-
const hasAnySource = diag.agentSessionsDirExists ||
|
|
1335
|
-
diag.cursorDirExists || diag.claudeDirExists || diag.codexDirExists ||
|
|
1336
|
-
Boolean(diag.cursorTranscriptsDir);
|
|
1337
|
-
if (!hasAnySource) {
|
|
1338
|
-
console.warn('[Daemon] No session sources detected at startup. Loop mode runs background self-maintenance but cannot observe a live agent without at least one of:');
|
|
1339
|
-
console.warn(` - ~/.openclaw/agents/<AGENT_NAME>/sessions/ (current AGENT_NAME=${diag.agentName}, exists=${diag.agentSessionsDirExists})`);
|
|
1340
|
-
console.warn(' - ~/.cursor / ~/.claude / ~/.codex (IDE transcripts)');
|
|
1341
|
-
console.warn(' - EVOLVER_CURSOR_TRANSCRIPTS_DIR (explicit override)');
|
|
1342
|
-
if (diag.availableOpenClawAgents.length > 0) {
|
|
1343
|
-
console.warn(` Available OpenClaw agents under ~/.openclaw/agents/: ${diag.availableOpenClawAgents.join(', ')}`);
|
|
1344
|
-
console.warn(' Set AGENT_NAME=<agent> or AGENT_SESSIONS_DIR=<abs path> to the one actually doing work.');
|
|
1345
|
-
}
|
|
1346
|
-
for (const hint of diag.hints) {
|
|
1347
|
-
console.warn(` HINT: ${hint}`);
|
|
1348
|
-
}
|
|
1349
|
-
console.warn(' If you want real-time agent assist (not background self-maintenance), run `evolver run` from inside the agent session instead of `evolver --loop`.');
|
|
1350
|
-
}
|
|
1351
|
-
} catch (_diagErr) { /* diagnostics must never block startup */ }
|
|
1352
|
-
|
|
1353
|
-
// Hub outcome mirror diagnostic. memoryGraph.syncEventToHub posts every
|
|
1354
|
-
// outcome/attempt/solidify/skill_emit event to <hub>/a2a/memory/event
|
|
1355
|
-
// by default, which is what populates this node's recall stream from
|
|
1356
|
-
// the Hub side (consumed by gep-mcp-server's gep_recall). It is silent
|
|
1357
|
-
// best-effort: failures don't crash the daemon but also don't surface,
|
|
1358
|
-
// so users get a "why does gep_recall return 0 matches" puzzle. A
|
|
1359
|
-
// single startup line says explicitly whether the mirror is on, what
|
|
1360
|
-
// node it would post as, and what to flip if you want it off.
|
|
1361
|
-
try {
|
|
1362
|
-
const a2a = require('./src/gep/a2aProtocol');
|
|
1363
|
-
const mirrorOff = process.env.MEMORY_GRAPH_SYNC_HUB === '0';
|
|
1364
|
-
const hubUrl = typeof a2a.getHubUrl === 'function' ? a2a.getHubUrl() : '';
|
|
1365
|
-
const nodeId = typeof a2a.getNodeId === 'function' ? a2a.getNodeId() : '';
|
|
1366
|
-
const hasSecret = typeof a2a.getHubNodeSecret === 'function' && !!a2a.getHubNodeSecret();
|
|
1367
|
-
if (mirrorOff) {
|
|
1368
|
-
console.log('[HubMirror] DISABLED — set MEMORY_GRAPH_SYNC_HUB=1 (or unset it) to mirror outcome events to <hub>/a2a/memory/event.');
|
|
1369
|
-
} else if (!hubUrl || !nodeId || !hasSecret) {
|
|
1370
|
-
console.log(`[HubMirror] inactive — missing one of: hub=${hubUrl ? 'OK' : 'MISSING'} node_id=${nodeId ? 'OK' : 'MISSING'} secret=${hasSecret ? 'OK' : 'MISSING'}. Local memory graph is unaffected.`);
|
|
1371
|
-
} else {
|
|
1372
|
-
console.log(`[HubMirror] ENABLED — outcome/attempt/solidify/skill_emit events mirror to ${hubUrl}/a2a/memory/event as ${nodeId}. Set MEMORY_GRAPH_SYNC_HUB=0 to disable.`);
|
|
1373
|
-
}
|
|
1374
|
-
} catch (_mirrorDiagErr) { /* diagnostics must never block startup */ }
|
|
1375
|
-
|
|
1376
|
-
// RecallVerify diagnostic banner: parallel to HubMirror but reads its
|
|
1377
|
-
// own env, since verification can run with HubMirror off (verifier
|
|
1378
|
-
// events are local-only on first ship).
|
|
1379
|
-
try {
|
|
1380
|
-
const enabled = String(process.env.EVOLVE_RECALL_VERIFY || '0') === '1';
|
|
1381
|
-
const sampleRateRaw = Number(process.env.EVOLVE_RECALL_VERIFY_SAMPLE_RATE);
|
|
1382
|
-
const sampleRate = Number.isFinite(sampleRateRaw) && sampleRateRaw >= 0 && sampleRateRaw <= 1 ? sampleRateRaw : 1.0;
|
|
1383
|
-
if (!enabled) {
|
|
1384
|
-
console.log('[RecallVerify] DISABLED (default) — opt-in observability only. Set EVOLVE_RECALL_VERIFY=1 to verify published assets round-trip via Hub Phase 2 lookup.');
|
|
1385
|
-
} else {
|
|
1386
|
-
console.log(`[RecallVerify] ENABLED — verifying published assets via Hub Phase 2 lookup, sample_rate=${sampleRate}. Set EVOLVE_RECALL_VERIFY=0 to disable.`);
|
|
1387
|
-
}
|
|
1388
|
-
} catch (_rvDiagErr) { /* diagnostics must never block startup */ }
|
|
1389
|
-
|
|
1390
|
-
const { getEvolutionDir, getEvolverLogPath } = require('./src/gep/paths');
|
|
1391
|
-
const solidifyStatePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json');
|
|
1392
|
-
const cycleProgressPath = path.join(getEvolutionDir(), 'cycle_progress.json');
|
|
1393
|
-
|
|
1394
|
-
const minSleepMs = parseMs(process.env.EVOLVER_MIN_SLEEP_MS, 2000);
|
|
1395
|
-
const maxSleepMs = parseMs(process.env.EVOLVER_MAX_SLEEP_MS, 300000);
|
|
1396
|
-
const idleThresholdMs = parseMs(process.env.EVOLVER_IDLE_THRESHOLD_MS, 500);
|
|
1397
|
-
const pendingSleepMs = parseMs(
|
|
1398
|
-
process.env.EVOLVE_PENDING_SLEEP_MS ||
|
|
1399
|
-
process.env.EVOLVE_MIN_INTERVAL,
|
|
1400
|
-
120000
|
|
1401
|
-
);
|
|
1402
|
-
|
|
1403
|
-
const maxCyclesPerProcess = parseMs(process.env.EVOLVER_MAX_CYCLES_PER_PROCESS, 100) || 100;
|
|
1404
|
-
const maxRssMb = parseMs(process.env.EVOLVER_MAX_RSS_MB, 500) || 500;
|
|
1405
|
-
const suicideEnabled = String(process.env.EVOLVER_SUICIDE || '').toLowerCase() !== 'false';
|
|
1406
|
-
|
|
1407
|
-
// Issue #19: hard timeout around evolve.run() to break out of zombie
|
|
1408
|
-
// cycles (e.g. unclosed socket / stuck LLM call). On timeout we throw
|
|
1409
|
-
// CycleTimeoutError, log diagnostic stderr, and force suicide-respawn
|
|
1410
|
-
// so the wrapper sees a fresh PID + cycle. Also write cycle_progress
|
|
1411
|
-
// every progressUpdateMs so the wrapper has a true heartbeat to poll.
|
|
1412
|
-
const cycleTimeoutEnabled = parseBoolEnv(process.env.EVOLVER_CYCLE_TIMEOUT_ENABLED, true);
|
|
1413
|
-
const cycleTimeoutMs = parseMs(process.env.EVOLVER_CYCLE_TIMEOUT_MS, 2700000); // 45 min default
|
|
1414
|
-
const progressUpdateMs = parseMs(process.env.EVOLVER_PROGRESS_UPDATE_MS, 60000); // 1 min default
|
|
1415
|
-
|
|
1416
|
-
// Start hub heartbeat (keeps node alive independently of evolution cycles)
|
|
1417
|
-
try {
|
|
1418
|
-
if (process.env.EVOMAP_PROXY === '1' || process.env.A2A_TRANSPORT === 'mailbox') {
|
|
1419
|
-
const { startProxy } = require('./src/proxy');
|
|
1420
|
-
const proxyInfo = await startProxy({
|
|
1421
|
-
hubUrl: process.env.A2A_HUB_URL,
|
|
1422
|
-
clientSettings: {},
|
|
1423
|
-
});
|
|
1424
|
-
console.log('[Proxy] Started on ' + proxyInfo.url);
|
|
1425
|
-
try {
|
|
1426
|
-
const { injectProxyEnv } = require('./src/proxy/inject');
|
|
1427
|
-
const injected = injectProxyEnv(proxyInfo);
|
|
1428
|
-
if (injected.injected) {
|
|
1429
|
-
console.log('[Proxy] Auto-injected client env for Claude Code/Codex/Cursor. Set EVOMAP_PROXY_AUTO_INJECT=off to disable.');
|
|
1430
|
-
} else {
|
|
1431
|
-
console.log('[Proxy] Auto-inject skipped: ' + injected.reason);
|
|
1432
|
-
}
|
|
1433
|
-
} catch (injectErr) {
|
|
1434
|
-
console.warn('[Proxy] Auto-inject failed: ' + (injectErr && injectErr.message || injectErr));
|
|
1435
|
-
}
|
|
1436
|
-
const { registerMailboxTransport } = require('./src/gep/mailboxTransport');
|
|
1437
|
-
registerMailboxTransport();
|
|
1438
|
-
process.env.A2A_TRANSPORT = 'mailbox';
|
|
1439
|
-
try {
|
|
1440
|
-
const a2a = require('./src/gep/a2aProtocol');
|
|
1441
|
-
a2a.startSystemdNotifyWatchdog(function () {
|
|
1442
|
-
try {
|
|
1443
|
-
const proxy = proxyInfo && proxyInfo.proxy;
|
|
1444
|
-
const lifecycle = proxy && proxy.lifecycle;
|
|
1445
|
-
if (lifecycle && typeof lifecycle.getHeartbeatStats === 'function') {
|
|
1446
|
-
const stats = lifecycle.getHeartbeatStats();
|
|
1447
|
-
// Hub-backed lifecycle stats are authoritative even when stopped;
|
|
1448
|
-
// systemd should starve and restart instead of seeing a false ping.
|
|
1449
|
-
if (stats && (stats.running || proxy.hubUrl)) return stats;
|
|
1450
|
-
}
|
|
1451
|
-
} catch (_) {}
|
|
1452
|
-
return { running: true, consecutiveFailures: 0, lastTickAt: Date.now() };
|
|
1453
|
-
});
|
|
1454
|
-
} catch (sdErr) {
|
|
1455
|
-
console.warn('[Heartbeat] systemd notify/watchdog setup failed: ' + (sdErr && sdErr.message || sdErr));
|
|
1456
|
-
}
|
|
1457
|
-
} else {
|
|
1458
|
-
const a2a = require('./src/gep/a2aProtocol');
|
|
1459
|
-
try { a2a.startHeartbeat(); }
|
|
1460
|
-
catch (hbErr) { console.warn('[Heartbeat] startHeartbeat failed: ' + (hbErr && hbErr.message || hbErr)); }
|
|
1461
|
-
try { a2a.startEventStream(); }
|
|
1462
|
-
catch (ssErr) { console.warn('[SSE] startEventStream failed: ' + (ssErr && ssErr.message || ssErr)); }
|
|
1463
|
-
}
|
|
1464
|
-
} catch (e) {
|
|
1465
|
-
console.warn('[Heartbeat] Failed to start: ' + (e.message || e));
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// RecallVerify worker: starts once per process; drains the publish-
|
|
1469
|
-
// verification queue with backoff. unref'd so it never blocks exit.
|
|
1470
|
-
try {
|
|
1471
|
-
if (String(process.env.EVOLVE_RECALL_VERIFY || '0') === '1') {
|
|
1472
|
-
require('./src/gep/recallVerifier').startWorker();
|
|
1473
|
-
}
|
|
1474
|
-
} catch (rvStartErr) {
|
|
1475
|
-
console.warn('[RecallVerify] startWorker failed: ' + (rvStartErr && rvStartErr.message || rvStartErr));
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
// Validator daemon: independent timer that fetches and executes
|
|
1479
|
-
// validation tasks regardless of the main evolve loop's idle gating.
|
|
1480
|
-
// Honors EVOLVER_VALIDATOR_ENABLED and the persisted feature flag.
|
|
1481
|
-
try {
|
|
1482
|
-
const { startValidatorDaemon } = require('./src/gep/validator');
|
|
1483
|
-
if (startValidatorDaemon()) {
|
|
1484
|
-
console.log('[ValidatorDaemon] started.');
|
|
1485
|
-
}
|
|
1486
|
-
} catch (vdErr) {
|
|
1487
|
-
console.warn('[ValidatorDaemon] failed to start: ' + (vdErr && vdErr.message || vdErr));
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// OAuth token auto-refresh: if a device-flow OAuth token is present,
|
|
1491
|
-
// keep it fresh in the background so long-running `evolver run` loops
|
|
1492
|
-
// never hit an expired a2a token mid-request. No-op for node_secret nodes.
|
|
1493
|
-
try {
|
|
1494
|
-
const { loadOAuthToken, startTokenAutoRefresh } = require('./src/gep/oauthLogin');
|
|
1495
|
-
if (loadOAuthToken()) {
|
|
1496
|
-
startTokenAutoRefresh();
|
|
1497
|
-
console.log('[OAuth] token auto-refresh scheduled.');
|
|
1498
|
-
}
|
|
1499
|
-
} catch (oauthRefreshErr) {
|
|
1500
|
-
console.warn('[OAuth] auto-refresh setup failed: ' + (oauthRefreshErr && oauthRefreshErr.message || oauthRefreshErr));
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
// ATP: auto-start merchant agent if enabled
|
|
1504
|
-
try {
|
|
1505
|
-
const { defaultHandler, merchantAgent } = require('./src/atp');
|
|
1506
|
-
const atpMode = defaultHandler.getAtpMode();
|
|
1507
|
-
if (atpMode === 'auto' || atpMode === 'on') {
|
|
1508
|
-
const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
|
|
1509
|
-
if (hubUrl) {
|
|
1510
|
-
const services = defaultHandler.resolveAtpServices();
|
|
1511
|
-
merchantAgent.start({
|
|
1512
|
-
services: services,
|
|
1513
|
-
onOrder: defaultHandler.defaultOrderHandler,
|
|
1514
|
-
pollMs: 30000,
|
|
1515
|
-
}).catch(function (atpErr) {
|
|
1516
|
-
console.warn('[ATP] merchantAgent.start failed: ' + (atpErr && atpErr.message || atpErr));
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
} catch (atpInitErr) {
|
|
1521
|
-
console.warn('[ATP] Auto-init failed: ' + (atpInitErr && atpInitErr.message || atpInitErr));
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
// ATP: capability-gap auto-buyer. OPT-IN as of consent-required
|
|
1525
|
-
// change — new installs do not auto-spend until the user explicitly
|
|
1526
|
-
// runs `evolver atp enable` or answers `y` at the first-run prompt.
|
|
1527
|
-
// Also starts the merchant-side auto-deliver daemon so claimed ATP
|
|
1528
|
-
// tasks actually call submitDelivery and settle instead of expiring.
|
|
1529
|
-
try {
|
|
1530
|
-
try {
|
|
1531
|
-
const { runPrompt } = require('./src/atp/cliAutobuyPrompt');
|
|
1532
|
-
await runPrompt();
|
|
1533
|
-
} catch (promptErr) {
|
|
1534
|
-
console.warn('[ATP-AutoBuyer] first-run prompt failed: ' + (promptErr && promptErr.message || promptErr));
|
|
1535
|
-
}
|
|
1536
|
-
const { autoBuyer } = require('./src/atp');
|
|
1537
|
-
const consent = autoBuyer.getConsent();
|
|
1538
|
-
if (consent.enabled) {
|
|
1539
|
-
const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
|
|
1540
|
-
if (hubUrl) {
|
|
1541
|
-
// Round-5: previously this bare start() call was a true
|
|
1542
|
-
// fire-and-forget. If autoBuyer.start returned a rejected
|
|
1543
|
-
// promise (transient hub error, bad config, mid-wake DNS
|
|
1544
|
-
// flap), the unhandledRejection escaped to the
|
|
1545
|
-
// process-level handler -- which, post round-3, only
|
|
1546
|
-
// exits if heartbeat is also dead. Net effect: daemon
|
|
1547
|
-
// stays alive but the autobuyer is half-initialized and
|
|
1548
|
-
// silently ignores claims. Attach a catch so the
|
|
1549
|
-
// operator can see the failure and the daemon-survival
|
|
1550
|
-
// gate is not relied on.
|
|
1551
|
-
try {
|
|
1552
|
-
const _autoBuyerPromise = autoBuyer.start({
|
|
1553
|
-
dailyCap: Number(process.env.ATP_AUTOBUY_DAILY_CAP_CREDITS) || undefined,
|
|
1554
|
-
perOrderCap: Number(process.env.ATP_AUTOBUY_PER_ORDER_CAP_CREDITS) || undefined,
|
|
1555
|
-
});
|
|
1556
|
-
if (_autoBuyerPromise && typeof _autoBuyerPromise.catch === 'function') {
|
|
1557
|
-
_autoBuyerPromise.catch(function (abErr) {
|
|
1558
|
-
console.warn('[ATP-AutoBuyer] start() rejected: ' + (abErr && abErr.message || abErr));
|
|
1559
|
-
});
|
|
1560
|
-
}
|
|
1561
|
-
} catch (abSyncErr) {
|
|
1562
|
-
console.warn('[ATP-AutoBuyer] start() threw synchronously: ' + (abSyncErr && abSyncErr.message || abSyncErr));
|
|
1563
|
-
}
|
|
1564
|
-
if (consent.source === 'default') {
|
|
1565
|
-
// First-run on a non-TTY (daemon, hook, CI) where the prompt
|
|
1566
|
-
// could not fire AND no env override + no ack file. autoBuyer
|
|
1567
|
-
// is starting with the default-on policy — surface a single
|
|
1568
|
-
// WARN per process so users see what is happening and how to
|
|
1569
|
-
// opt out, instead of discovering it via a credit balance dip.
|
|
1570
|
-
let safeHubUrl;
|
|
1571
|
-
try { safeHubUrl = new URL(hubUrl).origin; }
|
|
1572
|
-
catch { safeHubUrl = '(configured)'; }
|
|
1573
|
-
console.warn('[ATP-AutoBuyer] ATP auto-spend is ON (default for new installs).');
|
|
1574
|
-
console.warn(' Hub: ' + safeHubUrl + ' Caps: ' +
|
|
1575
|
-
(process.env.ATP_AUTOBUY_DAILY_CAP_CREDITS || '50') + ' credits/day, ' +
|
|
1576
|
-
(process.env.ATP_AUTOBUY_PER_ORDER_CAP_CREDITS || '10') + '/order' +
|
|
1577
|
-
' (cold-start half-cap for the first 5 min).');
|
|
1578
|
-
console.warn(' To opt out: evolver atp disable (or EVOLVER_ATP_AUTOBUY=off)');
|
|
1579
|
-
}
|
|
1580
|
-
} else {
|
|
1581
|
-
console.warn('[ATP-AutoBuyer] autobuy enabled but no hub URL configured, skipping.');
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
const autoDeliverRaw = (process.env.EVOLVER_ATP_AUTODELIVER || 'on').toLowerCase().trim();
|
|
1585
|
-
const autoDeliverOn = autoDeliverRaw !== 'off' && autoDeliverRaw !== '0' && autoDeliverRaw !== 'false';
|
|
1586
|
-
if (autoDeliverOn) {
|
|
1587
|
-
const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
|
|
1588
|
-
if (hubUrl) {
|
|
1589
|
-
const autoDeliver = require('./src/atp/autoDeliver');
|
|
1590
|
-
// Round-5: same fire-and-forget hardening as autoBuyer above.
|
|
1591
|
-
try {
|
|
1592
|
-
const _autoDeliverPromise = autoDeliver.start({
|
|
1593
|
-
pollMs: Number(process.env.ATP_AUTODELIVER_POLL_MS) || undefined,
|
|
1594
|
-
});
|
|
1595
|
-
if (_autoDeliverPromise && typeof _autoDeliverPromise.catch === 'function') {
|
|
1596
|
-
_autoDeliverPromise.catch(function (adErr) {
|
|
1597
|
-
console.warn('[ATP-AutoDeliver] start() rejected: ' + (adErr && adErr.message || adErr));
|
|
1598
|
-
});
|
|
1599
|
-
}
|
|
1600
|
-
} catch (adSyncErr) {
|
|
1601
|
-
console.warn('[ATP-AutoDeliver] start() threw synchronously: ' + (adSyncErr && adSyncErr.message || adSyncErr));
|
|
1602
|
-
}
|
|
1603
|
-
} else {
|
|
1604
|
-
console.warn('[ATP-AutoDeliver] autodeliver enabled but no hub URL configured, skipping.');
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
} catch (autoBuyInitErr) {
|
|
1608
|
-
console.warn('[ATP-AutoBuyer] Init failed: ' + (autoBuyInitErr && autoBuyInitErr.message || autoBuyInitErr));
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
// Hoist module refs used inside the loop to avoid repeated module lookups per cycle
|
|
1612
|
-
const idleScheduler = require('./src/gep/idleScheduler');
|
|
1613
|
-
const { shouldDistillFromFailures: shouldDF, autoDistillFromFailures: autoDF } = require('./src/gep/skillDistiller');
|
|
1614
|
-
const { autoDistillLlm } = require('./src/gep/autoDistillLlm'); // P3: autonomous LLM distillation (shadow-first, off by default)
|
|
1615
|
-
const { tryExplore } = require('./src/gep/explore');
|
|
1616
|
-
|
|
1617
|
-
let currentSleepMs = minSleepMs;
|
|
1618
|
-
let cycleCount = 0;
|
|
1619
|
-
|
|
1620
|
-
while (true) {
|
|
1621
|
-
try {
|
|
1622
|
-
cycleCount += 1;
|
|
1623
|
-
|
|
1624
|
-
// Ralph-loop gating: do not run a new cycle while previous run is pending solidify.
|
|
1625
|
-
const st0 = readJsonSafe(solidifyStatePath);
|
|
1626
|
-
if (isPendingSolidify(st0)) {
|
|
1627
|
-
await sleepMs(Math.max(pendingSleepMs, minSleepMs));
|
|
1628
|
-
continue;
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
const t0 = Date.now();
|
|
1632
|
-
let ok = false;
|
|
1633
|
-
// Issue #19: write progress at cycle start, refresh it every
|
|
1634
|
-
// progressUpdateMs (default 60s) while evolve.run() is active, and
|
|
1635
|
-
// wrap evolve.run() with Promise.race(timeout) so a hung internal
|
|
1636
|
-
// call cannot freeze the daemon for days.
|
|
1637
|
-
writeCycleProgressAtomic(cycleProgressPath, {
|
|
1638
|
-
pid: process.pid,
|
|
1639
|
-
outer_cycle: cycleCount,
|
|
1640
|
-
inner_cycle: cycleCount,
|
|
1641
|
-
started_at: t0,
|
|
1642
|
-
phase: 'evolve.run',
|
|
1643
|
-
});
|
|
1644
|
-
let progressTicker = null;
|
|
1645
|
-
if (progressUpdateMs > 0) {
|
|
1646
|
-
progressTicker = setInterval(function () {
|
|
1647
|
-
writeCycleProgressAtomic(cycleProgressPath, {
|
|
1648
|
-
pid: process.pid,
|
|
1649
|
-
outer_cycle: cycleCount,
|
|
1650
|
-
inner_cycle: cycleCount,
|
|
1651
|
-
started_at: t0,
|
|
1652
|
-
phase: 'evolve.run',
|
|
1653
|
-
});
|
|
1654
|
-
}, progressUpdateMs);
|
|
1655
|
-
if (typeof progressTicker.unref === 'function') progressTicker.unref();
|
|
1656
|
-
}
|
|
1657
|
-
let cycleTimeoutHandle = null;
|
|
1658
|
-
let evolvePromise = null;
|
|
1659
|
-
try {
|
|
1660
|
-
evolvePromise = evolve.run();
|
|
1661
|
-
if (cycleTimeoutEnabled && cycleTimeoutMs > 0) {
|
|
1662
|
-
const timeoutPromise = new Promise(function (_, reject) {
|
|
1663
|
-
cycleTimeoutHandle = setTimeout(function () {
|
|
1664
|
-
reject(new CycleTimeoutError(cycleTimeoutMs, 'evolve.run', cycleCount));
|
|
1665
|
-
}, cycleTimeoutMs);
|
|
1666
|
-
if (cycleTimeoutHandle && typeof cycleTimeoutHandle.unref === 'function') cycleTimeoutHandle.unref();
|
|
1667
|
-
});
|
|
1668
|
-
await Promise.race([evolvePromise, timeoutPromise]);
|
|
1669
|
-
} else {
|
|
1670
|
-
await evolvePromise;
|
|
1671
|
-
}
|
|
1672
|
-
ok = true;
|
|
1673
|
-
|
|
1674
|
-
if (String(process.env.EVOLVE_BRIDGE || '').toLowerCase() === 'false') {
|
|
1675
|
-
const stAfterRun = readJsonSafe(solidifyStatePath);
|
|
1676
|
-
if (isPendingSolidify(stAfterRun)) {
|
|
1677
|
-
const cleared = rejectPendingRun(solidifyStatePath);
|
|
1678
|
-
if (cleared) {
|
|
1679
|
-
console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode (state only, no rollback).');
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
} catch (error) {
|
|
1684
|
-
const msg = error && error.message ? String(error.message) : String(error);
|
|
1685
|
-
if (error && error.code === 'CYCLE_TIMEOUT') {
|
|
1686
|
-
if (progressTicker) { clearInterval(progressTicker); progressTicker = null; }
|
|
1687
|
-
if (cycleTimeoutHandle) { clearTimeout(cycleTimeoutHandle); cycleTimeoutHandle = null; }
|
|
1688
|
-
const timeoutAction = handleCycleTimeout({
|
|
1689
|
-
error,
|
|
1690
|
-
cycleProgressPath,
|
|
1691
|
-
progressFields: {
|
|
1692
|
-
pid: process.pid,
|
|
1693
|
-
outer_cycle: cycleCount,
|
|
1694
|
-
inner_cycle: cycleCount,
|
|
1695
|
-
started_at: t0,
|
|
1696
|
-
},
|
|
1697
|
-
suicideEnabled,
|
|
1698
|
-
args,
|
|
1699
|
-
logPath: getEvolverLogPath(),
|
|
1700
|
-
spawnReplacementFn: spawnReplacementProcess,
|
|
1701
|
-
});
|
|
1702
|
-
if (timeoutAction.action === 'respawn') {
|
|
1703
|
-
releaseLock();
|
|
1704
|
-
process.exit(1);
|
|
1705
|
-
}
|
|
1706
|
-
if (timeoutAction.action === 'continue') {
|
|
1707
|
-
await waitForTimedOutEvolvePromise(evolvePromise);
|
|
1708
|
-
}
|
|
1709
|
-
} else {
|
|
1710
|
-
console.error(`Evolution cycle failed: ${msg}`);
|
|
1711
|
-
}
|
|
1712
|
-
} finally {
|
|
1713
|
-
if (progressTicker) { clearInterval(progressTicker); progressTicker = null; }
|
|
1714
|
-
if (cycleTimeoutHandle) { clearTimeout(cycleTimeoutHandle); cycleTimeoutHandle = null; }
|
|
1715
|
-
}
|
|
1716
|
-
const dt = Date.now() - t0;
|
|
1717
|
-
|
|
1718
|
-
// Adaptive sleep: treat very fast cycles as "idle", backoff; otherwise reset to min.
|
|
1719
|
-
if (!ok || dt < idleThresholdMs) {
|
|
1720
|
-
currentSleepMs = Math.min(maxSleepMs, Math.max(minSleepMs, currentSleepMs * 2));
|
|
1721
|
-
} else {
|
|
1722
|
-
currentSleepMs = minSleepMs;
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
// OMLS-inspired idle scheduling: adjust sleep and trigger aggressive
|
|
1726
|
-
// operations (distillation, reflection) during detected idle windows.
|
|
1727
|
-
let omlsMultiplier = 1;
|
|
1728
|
-
try {
|
|
1729
|
-
const schedule = idleScheduler.getScheduleRecommendation();
|
|
1730
|
-
if (schedule.enabled && schedule.sleep_multiplier > 0) {
|
|
1731
|
-
omlsMultiplier = schedule.sleep_multiplier;
|
|
1732
|
-
if (schedule.should_distill) {
|
|
1733
|
-
try {
|
|
1734
|
-
if (shouldDF()) {
|
|
1735
|
-
const dfResult = autoDF();
|
|
1736
|
-
if (dfResult && dfResult.ok) {
|
|
1737
|
-
console.log('[OMLS] Idle-window failure distillation: ' + dfResult.gene.id);
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
} catch (e) {
|
|
1741
|
-
if (isVerbose) console.warn('[OMLS] Distill error: ' + (e.message || e));
|
|
1742
|
-
}
|
|
1743
|
-
// P3: autonomous LLM-quality distillation of SUCCESS capsules.
|
|
1744
|
-
// Default off; shadow logs a candidate; enforce upserts (after a
|
|
1745
|
-
// real run-green gate). Reuses the P1 exec bridge under the hood.
|
|
1746
|
-
if ((process.env.EVOLVER_AUTO_DISTILL_LLM || 'off') !== 'off') {
|
|
1747
|
-
try {
|
|
1748
|
-
const llmRes = await autoDistillLlm();
|
|
1749
|
-
if (llmRes && llmRes.ok && llmRes.gene) {
|
|
1750
|
-
console.log('[OMLS] Idle-window LLM distillation enforced gene: ' + llmRes.gene.id);
|
|
1751
|
-
} else if (llmRes && llmRes.reason === 'shadow_logged') {
|
|
1752
|
-
console.log('[OMLS] LLM distillation shadow candidate: ' + (llmRes.candidate && llmRes.candidate.id));
|
|
1753
|
-
}
|
|
1754
|
-
} catch (e) {
|
|
1755
|
-
if (isVerbose) console.warn('[OMLS] LLM distill error (non-fatal): ' + (e.message || e));
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
if (schedule.should_explore) {
|
|
1760
|
-
try {
|
|
1761
|
-
const exploreResult = await tryExplore([], schedule, getRepoRoot());
|
|
1762
|
-
if (exploreResult && exploreResult.signals && exploreResult.signals.length > 0) {
|
|
1763
|
-
console.log('[OMLS] Explore discovered ' + exploreResult.signals.length + ' signals: ' + exploreResult.signals.slice(0, 5).join(', '));
|
|
1764
|
-
}
|
|
1765
|
-
} catch (e) {
|
|
1766
|
-
if (isVerbose) console.warn('[OMLS] Explore error: ' + (e.message || e));
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
// P2: conversation capability -> distilled gene (shadow-only v1).
|
|
1770
|
-
// Deliberately OUTSIDE the should_distill guard: should_distill is
|
|
1771
|
-
// true only at aggressive/deep intensity, but headless/air-gapped
|
|
1772
|
-
// hosts fall back to 'normal', which would make P2 a dead feature.
|
|
1773
|
-
// A freshly-discovered capability is time-relevant; gate it solely on
|
|
1774
|
-
// its own flag + the per-slug cooldown + a non-empty queue (all of
|
|
1775
|
-
// which already bound spend). Default off => zero behavior change.
|
|
1776
|
-
if ((process.env.EVOLVER_CONV_DISTILL_ENABLED || 'off') !== 'off') {
|
|
1777
|
-
try {
|
|
1778
|
-
const { autoDistillConversation } = require('./src/gep/autoDistillConv');
|
|
1779
|
-
const convRes = await autoDistillConversation();
|
|
1780
|
-
if (convRes && convRes.ok) console.log('[P2] conv-distill ' + convRes.mode + ' candidate: ' + (convRes.gene_id || convRes.reason));
|
|
1781
|
-
} catch (e) {
|
|
1782
|
-
if (isVerbose) console.warn('[P2] conv-distill error (non-fatal): ' + (e.message || e));
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
if (isVerbose && schedule.idle_seconds >= 0) {
|
|
1786
|
-
console.log(`[OMLS] idle=${schedule.idle_seconds}s intensity=${schedule.intensity} multiplier=${omlsMultiplier}`);
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
} catch (e) {
|
|
1790
|
-
if (isVerbose) console.warn('[OMLS] Scheduler error: ' + (e.message || e));
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
// Suicide check (memory leak protection). On Windows the
|
|
1794
|
-
// in-process respawn opens a cmd popup (Issue #528), so by default
|
|
1795
|
-
// we delegate to an external supervisor by exiting with a non-zero
|
|
1796
|
-
// code instead. See spawnReplacementProcess() for the policy.
|
|
1797
|
-
if (suicideEnabled) {
|
|
1798
|
-
const memMb = process.memoryUsage().rss / 1024 / 1024;
|
|
1799
|
-
if (cycleCount >= maxCyclesPerProcess || memMb > maxRssMb) {
|
|
1800
|
-
console.log(`[Daemon] Restarting self (cycles=${cycleCount}, rssMb=${memMb.toFixed(0)})`);
|
|
1801
|
-
const result = spawnReplacementProcess({
|
|
1802
|
-
reason: 'max_cycles_or_rss',
|
|
1803
|
-
args: args,
|
|
1804
|
-
logPath: getEvolverLogPath(),
|
|
1805
|
-
});
|
|
1806
|
-
if (result.spawned) {
|
|
1807
|
-
releaseLock();
|
|
1808
|
-
process.exit(0);
|
|
1809
|
-
} else if (result.reason === 'windows_default_skip') {
|
|
1810
|
-
console.log('[Daemon] Exiting with code 1 to let external supervisor respawn.');
|
|
1811
|
-
releaseLock();
|
|
1812
|
-
process.exit(1);
|
|
1813
|
-
} else {
|
|
1814
|
-
// Non-Windows spawn error: keep the lock and fall through to
|
|
1815
|
-
// the next iteration of the loop instead of leaving the daemon
|
|
1816
|
-
// dead. This matches the pre-1.79.1 behavior where a failed
|
|
1817
|
-
// spawn was logged and the process continued running.
|
|
1818
|
-
console.error('[Daemon] Spawn failed, continuing current process.');
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
let saturationMultiplier = 1;
|
|
1824
|
-
try {
|
|
1825
|
-
const lastSignals = getLastSignals(solidifyStatePath);
|
|
1826
|
-
if (lastSignals.includes('force_steady_state')) {
|
|
1827
|
-
saturationMultiplier = 4;
|
|
1828
|
-
console.log('[Daemon] Saturation detected. Entering steady-state mode (4x sleep).');
|
|
1829
|
-
} else if (lastSignals.includes('evolution_saturation')) {
|
|
1830
|
-
saturationMultiplier = 2;
|
|
1831
|
-
console.log('[Daemon] Approaching saturation. Reducing evolution frequency (2x sleep).');
|
|
1832
|
-
}
|
|
1833
|
-
} catch (e) {
|
|
1834
|
-
if (isVerbose) console.warn('[Daemon] Saturation check error: ' + (e.message || e));
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
// Jitter to avoid lockstep restarts.
|
|
1838
|
-
const jitter = Math.floor(Math.random() * 250);
|
|
1839
|
-
const totalSleepMs = Math.max(minSleepMs, (currentSleepMs + jitter) * saturationMultiplier * omlsMultiplier);
|
|
1840
|
-
if (isVerbose) {
|
|
1841
|
-
const memMb = (process.memoryUsage().rss / 1024 / 1024).toFixed(1);
|
|
1842
|
-
const signals = getLastSignals(solidifyStatePath).join(',');
|
|
1843
|
-
console.log(`[Verbose] cycle=${cycleCount} ok=${ok} dt=${dt}ms sleep=${totalSleepMs}ms (base=${currentSleepMs} jitter=${jitter} sat=${saturationMultiplier}x) rss=${memMb}MB signals=[${signals}]`);
|
|
1844
|
-
}
|
|
1845
|
-
writeCycleProgressAtomic(cycleProgressPath, {
|
|
1846
|
-
pid: process.pid,
|
|
1847
|
-
outer_cycle: cycleCount,
|
|
1848
|
-
inner_cycle: cycleCount,
|
|
1849
|
-
started_at: t0,
|
|
1850
|
-
phase: 'sleep',
|
|
1851
|
-
});
|
|
1852
|
-
await sleepMs(totalSleepMs);
|
|
1853
|
-
|
|
1854
|
-
} catch (loopErr) {
|
|
1855
|
-
console.error('[Daemon] Unexpected loop error (recovering): ' + (loopErr && loopErr.message ? loopErr.message : String(loopErr)));
|
|
1856
|
-
await sleepMs(Math.max(minSleepMs, 10000));
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
} else {
|
|
1860
|
-
// Normal Single Run
|
|
1861
|
-
try {
|
|
1862
|
-
await evolve.run();
|
|
1863
|
-
} catch (error) {
|
|
1864
|
-
console.error('Evolution failed:', error);
|
|
1865
|
-
process.exit(1);
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
// Post-run hint
|
|
1870
|
-
console.log('\n' + '=======================================================');
|
|
1871
|
-
console.log('Evolver finished. If you use this project, consider starring the upstream repository.');
|
|
1872
|
-
console.log('Upstream: https://github.com/EvoMap/evolver');
|
|
1873
|
-
console.log('=======================================================\n');
|
|
1874
|
-
|
|
1875
|
-
} else if (command === 'solidify') {
|
|
1876
|
-
const dryRun = args.includes('--dry-run');
|
|
1877
|
-
const noRollback = args.includes('--no-rollback');
|
|
1878
|
-
const intentFlag = args.find(a => typeof a === 'string' && a.startsWith('--intent='));
|
|
1879
|
-
const summaryFlag = args.find(a => typeof a === 'string' && a.startsWith('--summary='));
|
|
1880
|
-
const intent = intentFlag ? intentFlag.slice('--intent='.length) : null;
|
|
1881
|
-
const summary = summaryFlag ? summaryFlag.slice('--summary='.length) : null;
|
|
1882
|
-
|
|
1883
|
-
try {
|
|
1884
|
-
const res = await solidify({
|
|
1885
|
-
intent: intent || undefined,
|
|
1886
|
-
summary: summary || undefined,
|
|
1887
|
-
dryRun,
|
|
1888
|
-
rollbackOnFailure: !noRollback,
|
|
1889
|
-
});
|
|
1890
|
-
const st = res && res.ok ? 'SUCCESS' : 'FAILED';
|
|
1891
|
-
console.log(`[SOLIDIFY] ${st}`);
|
|
1892
|
-
if (res && res.gene) console.log(JSON.stringify(res.gene, null, 2));
|
|
1893
|
-
if (res && res.event) console.log(JSON.stringify(res.event, null, 2));
|
|
1894
|
-
if (res && res.capsule) console.log(JSON.stringify(res.capsule, null, 2));
|
|
1895
|
-
|
|
1896
|
-
if (res && res.ok && !dryRun) {
|
|
1897
|
-
try {
|
|
1898
|
-
const { shouldDistill, prepareDistillation, autoDistill, shouldDistillFromFailures, autoDistillFromFailures } = require('./src/gep/skillDistiller');
|
|
1899
|
-
const { readStateForSolidify } = require('./src/gep/solidify');
|
|
1900
|
-
const solidifyState = readStateForSolidify();
|
|
1901
|
-
const count = solidifyState.solidify_count || 0;
|
|
1902
|
-
const autoDistillInterval = 5;
|
|
1903
|
-
const autoTrigger = count > 0 && count % autoDistillInterval === 0;
|
|
1904
|
-
|
|
1905
|
-
if (autoTrigger || shouldDistill()) {
|
|
1906
|
-
const auto = autoDistill();
|
|
1907
|
-
if (auto && auto.ok && auto.gene) {
|
|
1908
|
-
console.log('[Distiller] Auto-distilled gene: ' + auto.gene.id);
|
|
1909
|
-
} else {
|
|
1910
|
-
const dr = prepareDistillation();
|
|
1911
|
-
if (dr && dr.ok && dr.promptPath) {
|
|
1912
|
-
const trigger = autoTrigger ? `auto (every ${autoDistillInterval} solidifies, count=${count})` : 'threshold';
|
|
1913
|
-
console.log('\n[DISTILL_REQUEST]');
|
|
1914
|
-
console.log(`Distillation triggered: ${trigger}`);
|
|
1915
|
-
console.log('Read the prompt file, process it with your LLM,');
|
|
1916
|
-
console.log('save the LLM response to a file, then run:');
|
|
1917
|
-
console.log(' node index.js distill --response-file=<path_to_llm_response>');
|
|
1918
|
-
console.log('Prompt file: ' + dr.promptPath);
|
|
1919
|
-
console.log('[/DISTILL_REQUEST]');
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
if (shouldDistillFromFailures()) {
|
|
1925
|
-
const failureResult = autoDistillFromFailures();
|
|
1926
|
-
if (failureResult && failureResult.ok && failureResult.gene) {
|
|
1927
|
-
console.log('[Distiller] Repair gene distilled from failures: ' + failureResult.gene.id);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
} catch (e) {
|
|
1931
|
-
console.warn('[Distiller] Init failed (non-fatal): ' + (e.message || e));
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
if (res && res.hubReviewPromise) {
|
|
1936
|
-
await res.hubReviewPromise;
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// Post-solidify urgent questions: when solidify fails or produces a
|
|
1940
|
-
// low-quality outcome, generate questions and send them to Hub immediately.
|
|
1941
|
-
if (!dryRun) {
|
|
1942
|
-
try {
|
|
1943
|
-
const { generateUrgentQuestions } = require('./src/gep/questionGenerator');
|
|
1944
|
-
const { fetchTasks } = require('./src/gep/taskReceiver');
|
|
1945
|
-
const urgentOpts = {};
|
|
1946
|
-
|
|
1947
|
-
if (!res || !res.ok) {
|
|
1948
|
-
if (res && res.validation && !res.validation.ok) {
|
|
1949
|
-
urgentOpts.validationFailed = true;
|
|
1950
|
-
const failedStep = res.validation.results && res.validation.results.find(function (r) { return !r.ok; });
|
|
1951
|
-
urgentOpts.validationErrors = failedStep ? (failedStep.err || failedStep.cmd || '') : '';
|
|
1952
|
-
}
|
|
1953
|
-
urgentOpts.geneId = res && res.gene ? res.gene.id : undefined;
|
|
1954
|
-
const evtOutcome = res && res.event && res.event.outcome;
|
|
1955
|
-
if (evtOutcome && typeof evtOutcome.score === 'number' && evtOutcome.score < 0.3) {
|
|
1956
|
-
urgentOpts.lowConfidence = true;
|
|
1957
|
-
urgentOpts.confidenceScore = evtOutcome.score;
|
|
1958
|
-
urgentOpts.intent = res.event.intent;
|
|
1959
|
-
}
|
|
1960
|
-
if (res && res.blast && res.blast.files === 0 && res.blast.lines === 0) {
|
|
1961
|
-
urgentOpts.zeroBlastRadius = true;
|
|
1962
|
-
urgentOpts.hadSignals = true;
|
|
1963
|
-
urgentOpts.signals = res.event && Array.isArray(res.event.signals) ? res.event.signals : [];
|
|
1964
|
-
}
|
|
1965
|
-
if (res && res.constraintCheck && Array.isArray(res.constraintCheck.violations)) {
|
|
1966
|
-
const llmRejectV = res.constraintCheck.violations.find(function (v) { return String(v).startsWith('llm_review_rejected'); });
|
|
1967
|
-
if (llmRejectV) {
|
|
1968
|
-
urgentOpts.llmReviewRejected = true;
|
|
1969
|
-
urgentOpts.llmReviewReason = String(llmRejectV).replace('llm_review_rejected: ', '');
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
const lr = readJsonSafe(path.join(require('./src/gep/paths').getEvolutionDir(), 'evolution_solidify_state.json'));
|
|
1973
|
-
if (lr && lr.last_run && lr.last_run.active_task_id) {
|
|
1974
|
-
urgentOpts.taskCompletionFailed = true;
|
|
1975
|
-
urgentOpts.taskTitle = lr.last_run.active_task_title || '';
|
|
1976
|
-
urgentOpts.taskSignals = Array.isArray(lr.last_run.task_signals) ? lr.last_run.task_signals.join(', ') : '';
|
|
1977
|
-
}
|
|
1978
|
-
} else if (res.event && res.event.outcome && res.event.outcome.score < 0.3) {
|
|
1979
|
-
urgentOpts.lowConfidence = true;
|
|
1980
|
-
urgentOpts.confidenceScore = res.event.outcome.score;
|
|
1981
|
-
urgentOpts.intent = res.event.intent;
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
if (Object.keys(urgentOpts).length > 0) {
|
|
1985
|
-
const urgentQs = generateUrgentQuestions(urgentOpts);
|
|
1986
|
-
if (urgentQs.length > 0) {
|
|
1987
|
-
console.log('[UrgentQ] Generated ' + urgentQs.length + ' urgent question(s) from solidify outcome.');
|
|
1988
|
-
try {
|
|
1989
|
-
const fetchRes = await fetchTasks({ questions: urgentQs });
|
|
1990
|
-
if (fetchRes.questions_created) {
|
|
1991
|
-
const accepted = fetchRes.questions_created.filter(function (q) { return !q.error; });
|
|
1992
|
-
if (accepted.length > 0) {
|
|
1993
|
-
console.log('[UrgentQ] Hub accepted ' + accepted.length + ' urgent question(s) as bounties.');
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
} catch (err) {
|
|
1997
|
-
console.log('[UrgentQ] Send failed (non-fatal): ' + (err && err.message ? err.message : err));
|
|
1998
|
-
}
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
} catch (e) {
|
|
2002
|
-
console.log('[UrgentQ] Init failed (non-fatal): ' + (e && e.message ? e.message : e));
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
process.exit(res && res.ok ? 0 : 2);
|
|
2007
|
-
} catch (error) {
|
|
2008
|
-
console.error('[SOLIDIFY] Error:', error);
|
|
2009
|
-
process.exit(2);
|
|
2010
|
-
}
|
|
2011
|
-
} else if (command === 'exec') {
|
|
2012
|
-
// node index.js exec --harness=claude-code [--once] [--max-cycles N]
|
|
2013
|
-
// P1 auto-exec bridge: run the Brain, scrape its sessions_spawn(...), spawn
|
|
2014
|
-
// the Hand (headless claude) to apply + solidify. Shadow-first opt-in.
|
|
2015
|
-
if (String(process.env.EVOLVE_EXEC_BRIDGE || '').toLowerCase() !== 'true') {
|
|
2016
|
-
console.error('[exec] EVOLVE_EXEC_BRIDGE is not "true". The auto-exec bridge is opt-in. Refusing.');
|
|
2017
|
-
process.exit(2);
|
|
2018
|
-
}
|
|
2019
|
-
const getFlag = (n) => {
|
|
2020
|
-
const i = args.findIndex(a => a === `--${n}` || a.startsWith(`--${n}=`));
|
|
2021
|
-
if (i === -1) return undefined;
|
|
2022
|
-
const h = args[i];
|
|
2023
|
-
if (h.includes('=')) return h.split('=').slice(1).join('='); // --n=value
|
|
2024
|
-
// bare --n: if the next token is a value (not another --flag), consume it
|
|
2025
|
-
// (#179 r6: support `--max-cycles N` space-separated, not just =N). A
|
|
2026
|
-
// trailing bare flag with no following value stays boolean true (e.g. --once).
|
|
2027
|
-
const next = args[i + 1];
|
|
2028
|
-
return (next !== undefined && !next.startsWith('--')) ? next : true;
|
|
2029
|
-
};
|
|
2030
|
-
const harness = String(getFlag('harness') || 'claude-code');
|
|
2031
|
-
const once = getFlag('once') === true;
|
|
2032
|
-
// #179 r7: validate --max-cycles. Number('foo')||0 silently became 0 =
|
|
2033
|
-
// unbounded daemon — a typo must fail fast, not run forever. Absent flag =>
|
|
2034
|
-
// 0 (intentional unbounded). A present value must be a non-negative integer.
|
|
2035
|
-
const rawMaxCycles = getFlag('max-cycles');
|
|
2036
|
-
let maxCycles = 0;
|
|
2037
|
-
if (rawMaxCycles !== undefined && rawMaxCycles !== true) {
|
|
2038
|
-
const n = Number(rawMaxCycles);
|
|
2039
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
2040
|
-
console.error(`[exec] invalid --max-cycles '${rawMaxCycles}' (expected a non-negative integer; 0 or omit = unbounded)`);
|
|
2041
|
-
process.exit(2);
|
|
2042
|
-
}
|
|
2043
|
-
maxCycles = n;
|
|
2044
|
-
} else if (rawMaxCycles === true) {
|
|
2045
|
-
console.error('[exec] --max-cycles requires a value (e.g. --max-cycles 5 or --max-cycles=5)');
|
|
2046
|
-
process.exit(2);
|
|
2047
|
-
}
|
|
2048
|
-
if (!['claude-code', 'openclaw', 'codex', 'opencode'].includes(harness)) {
|
|
2049
|
-
console.error(`[exec] unknown --harness '${harness}' (expected claude-code | openclaw | codex | opencode)`);
|
|
2050
|
-
process.exit(2);
|
|
2051
|
-
}
|
|
2052
|
-
try {
|
|
2053
|
-
const { runExecBridge } = require('./src/gep/execBridge');
|
|
2054
|
-
const res = await runExecBridge({ harness, once, maxCycles });
|
|
2055
|
-
console.log(`[exec] done: cycles=${res.cycles} lastOutcome=${res.lastOutcome}`);
|
|
2056
|
-
// Exit 0 only on a genuine success. A bounded/daemon run that ended in
|
|
2057
|
-
// hand_failed/brain_failed/no_spawn must report non-zero to shells & CI
|
|
2058
|
-
// (Bugbot #179: do not exit 0 on failure just because cycles>0).
|
|
2059
|
-
process.exit(res.lastOutcome === 'success' ? 0 : 1);
|
|
2060
|
-
} catch (error) {
|
|
2061
|
-
console.error('[exec] bridge error:', error && error.message ? error.message : error);
|
|
2062
|
-
process.exit(1);
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
} else if (command === 'distill') {
|
|
2066
|
-
const responseFileFlag = args.find(a => typeof a === 'string' && a.startsWith('--response-file='));
|
|
2067
|
-
if (!responseFileFlag) {
|
|
2068
|
-
console.error('Usage: node index.js distill --response-file=<path>');
|
|
2069
|
-
process.exit(1);
|
|
2070
|
-
}
|
|
2071
|
-
const responseFilePath = responseFileFlag.slice('--response-file='.length);
|
|
2072
|
-
{
|
|
2073
|
-
const { getRepoRoot } = require('./src/gep/paths');
|
|
2074
|
-
const resolvedResponsePath = path.resolve(responseFilePath);
|
|
2075
|
-
const resolvedRepoRoot = path.resolve(getRepoRoot());
|
|
2076
|
-
if (responseFilePath.includes('..') || !resolvedResponsePath.startsWith(resolvedRepoRoot)) {
|
|
2077
|
-
console.error('[Distill] ERROR: Invalid response-file path "' + responseFilePath + '" - path traversal detected or path is outside the repository.');
|
|
2078
|
-
process.exit(2);
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
try {
|
|
2082
|
-
const responseText = fs.readFileSync(responseFilePath, 'utf8');
|
|
2083
|
-
const { completeDistillation } = require('./src/gep/skillDistiller');
|
|
2084
|
-
const result = completeDistillation(responseText);
|
|
2085
|
-
if (result && result.ok) {
|
|
2086
|
-
console.log('[Distiller] Gene produced: ' + result.gene.id);
|
|
2087
|
-
console.log(JSON.stringify(result.gene, null, 2));
|
|
2088
|
-
} else {
|
|
2089
|
-
console.warn('[Distiller] Distillation did not produce a gene: ' + (result && result.reason || 'unknown'));
|
|
2090
|
-
}
|
|
2091
|
-
process.exit(result && result.ok ? 0 : 2);
|
|
2092
|
-
} catch (error) {
|
|
2093
|
-
console.error('[DISTILL] Error:', error);
|
|
2094
|
-
process.exit(2);
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
} else if (command === 'review' || command === '--review') {
|
|
2098
|
-
const { getEvolutionDir, getRepoRoot } = require('./src/gep/paths');
|
|
2099
|
-
const { loadGenes } = require('./src/gep/assetStore');
|
|
2100
|
-
const { execSync } = require('child_process');
|
|
2101
|
-
const MAX_EXEC_BUFFER = 10 * 1024 * 1024; // 10MB; see GHSA reports / #451
|
|
2102
|
-
|
|
2103
|
-
const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json');
|
|
2104
|
-
const state = readJsonSafe(statePath);
|
|
2105
|
-
const lastRun = state && state.last_run ? state.last_run : null;
|
|
2106
|
-
|
|
2107
|
-
if (!lastRun || !lastRun.run_id) {
|
|
2108
|
-
console.log('[Review] No pending evolution run to review.');
|
|
2109
|
-
console.log('Run "node index.js run" first to produce changes, then review before solidifying.');
|
|
2110
|
-
process.exit(0);
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
const lastSolid = state && state.last_solidify ? state.last_solidify : null;
|
|
2114
|
-
if (lastSolid && String(lastSolid.run_id) === String(lastRun.run_id)) {
|
|
2115
|
-
console.log('[Review] Last run has already been solidified. Nothing to review.');
|
|
2116
|
-
process.exit(0);
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
const repoRoot = getRepoRoot();
|
|
2120
|
-
let diff = '';
|
|
2121
|
-
try {
|
|
2122
|
-
const unstaged = execSync('git diff', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
|
|
2123
|
-
const staged = execSync('git diff --cached', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
|
|
2124
|
-
const untracked = execSync('git ls-files --others --exclude-standard', { cwd: repoRoot, encoding: 'utf8', timeout: 10000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
|
|
2125
|
-
if (staged) diff += '=== Staged Changes ===\n' + staged + '\n\n';
|
|
2126
|
-
if (unstaged) diff += '=== Unstaged Changes ===\n' + unstaged + '\n\n';
|
|
2127
|
-
if (untracked) diff += '=== Untracked Files ===\n' + untracked + '\n';
|
|
2128
|
-
} catch (e) {
|
|
2129
|
-
diff = '(failed to capture diff: ' + (e.message || e) + ')';
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
const genes = loadGenes();
|
|
2133
|
-
const geneId = lastRun.selected_gene_id ? String(lastRun.selected_gene_id) : null;
|
|
2134
|
-
const gene = geneId ? genes.find(g => g && g.type === 'Gene' && g.id === geneId) : null;
|
|
2135
|
-
const signals = Array.isArray(lastRun.signals) ? lastRun.signals : [];
|
|
2136
|
-
const mutation = lastRun.mutation || null;
|
|
2137
|
-
|
|
2138
|
-
console.log('\n' + '='.repeat(60));
|
|
2139
|
-
console.log('[Review] Pending evolution run: ' + lastRun.run_id);
|
|
2140
|
-
console.log('='.repeat(60));
|
|
2141
|
-
console.log('\n--- Gene ---');
|
|
2142
|
-
if (gene) {
|
|
2143
|
-
console.log(' ID: ' + gene.id);
|
|
2144
|
-
console.log(' Category: ' + (gene.category || '?'));
|
|
2145
|
-
console.log(' Summary: ' + (gene.summary || '?'));
|
|
2146
|
-
if (Array.isArray(gene.strategy) && gene.strategy.length > 0) {
|
|
2147
|
-
console.log(' Strategy:');
|
|
2148
|
-
gene.strategy.forEach((s, i) => console.log(' ' + (i + 1) + '. ' + s));
|
|
2149
|
-
}
|
|
2150
|
-
} else {
|
|
2151
|
-
console.log(' (no gene selected or gene not found: ' + (geneId || 'none') + ')');
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
console.log('\n--- Signals ---');
|
|
2155
|
-
if (signals.length > 0) {
|
|
2156
|
-
signals.forEach(s => console.log(' - ' + s));
|
|
2157
|
-
} else {
|
|
2158
|
-
console.log(' (no signals)');
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
console.log('\n--- Mutation ---');
|
|
2162
|
-
if (mutation) {
|
|
2163
|
-
console.log(' Category: ' + (mutation.category || '?'));
|
|
2164
|
-
console.log(' Risk Level: ' + (mutation.risk_level || '?'));
|
|
2165
|
-
if (mutation.rationale) console.log(' Rationale: ' + mutation.rationale);
|
|
2166
|
-
} else {
|
|
2167
|
-
console.log(' (no mutation data)');
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
if (lastRun.blast_radius_estimate) {
|
|
2171
|
-
console.log('\n--- Blast Radius Estimate ---');
|
|
2172
|
-
const br = lastRun.blast_radius_estimate;
|
|
2173
|
-
console.log(' Files changed: ' + (br.files_changed || '?'));
|
|
2174
|
-
console.log(' Lines changed: ' + (br.lines_changed || '?'));
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
console.log('\n--- Diff ---');
|
|
2178
|
-
if (diff.trim()) {
|
|
2179
|
-
console.log(diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, ' + diff.length + ' chars total)' : diff);
|
|
2180
|
-
} else {
|
|
2181
|
-
console.log(' (no changes detected)');
|
|
2182
|
-
}
|
|
2183
|
-
console.log('='.repeat(60));
|
|
2184
|
-
|
|
2185
|
-
if (args.includes('--approve')) {
|
|
2186
|
-
console.log('\n[Review] Approved. Running solidify...\n');
|
|
2187
|
-
try {
|
|
2188
|
-
const res = await solidify({
|
|
2189
|
-
intent: lastRun.intent || undefined,
|
|
2190
|
-
rollbackOnFailure: true,
|
|
2191
|
-
});
|
|
2192
|
-
const st = res && res.ok ? 'SUCCESS' : 'FAILED';
|
|
2193
|
-
console.log(`[SOLIDIFY] ${st}`);
|
|
2194
|
-
if (res && res.gene) console.log(JSON.stringify(res.gene, null, 2));
|
|
2195
|
-
if (res && res.hubReviewPromise) {
|
|
2196
|
-
await res.hubReviewPromise;
|
|
2197
|
-
}
|
|
2198
|
-
process.exit(res && res.ok ? 0 : 2);
|
|
2199
|
-
} catch (error) {
|
|
2200
|
-
console.error('[SOLIDIFY] Error:', error);
|
|
2201
|
-
process.exit(2);
|
|
2202
|
-
}
|
|
2203
|
-
} else if (args.includes('--reject')) {
|
|
2204
|
-
console.log('\n[Review] Rejected. Rolling back changes...');
|
|
2205
|
-
try {
|
|
2206
|
-
execSync('git checkout -- .', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true });
|
|
2207
|
-
// Preserve user state on reject: .env files, node_modules, runtime
|
|
2208
|
-
// PID files, and a dedicated workspace/ dir (if one exists) MUST NOT
|
|
2209
|
-
// be wiped by an automated rollback. Users have reported losing
|
|
2210
|
-
// secrets and runtime caches to an aggressive git clean.
|
|
2211
|
-
execSync('git clean -fd -e node_modules -e workspace -e .env -e ".env.*" -e "*.pid"', {
|
|
2212
|
-
cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true,
|
|
2213
|
-
});
|
|
2214
|
-
const evolDir = getEvolutionDir();
|
|
2215
|
-
const sp = path.join(evolDir, 'evolution_solidify_state.json');
|
|
2216
|
-
if (fs.existsSync(sp)) {
|
|
2217
|
-
const s = readJsonSafe(sp);
|
|
2218
|
-
if (s && s.last_run) {
|
|
2219
|
-
s.last_solidify = { run_id: s.last_run.run_id, rejected: true, timestamp: new Date().toISOString() };
|
|
2220
|
-
const tmpReject = `${sp}.tmp`;
|
|
2221
|
-
fs.writeFileSync(tmpReject, JSON.stringify(s, null, 2) + '\n', 'utf8');
|
|
2222
|
-
fs.renameSync(tmpReject, sp);
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
console.log('[Review] Changes rolled back.');
|
|
2226
|
-
} catch (e) {
|
|
2227
|
-
console.error('[Review] Rollback failed:', e.message || e);
|
|
2228
|
-
process.exit(2);
|
|
2229
|
-
}
|
|
2230
|
-
} else {
|
|
2231
|
-
console.log('\nTo approve and solidify: node index.js review --approve');
|
|
2232
|
-
console.log('To reject and rollback: node index.js review --reject');
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
} else if (command === 'fetch') {
|
|
2236
|
-
let skillId = null;
|
|
2237
|
-
const eqFlag = args.find(a => typeof a === 'string' && (a.startsWith('--skill=') || a.startsWith('-s=')));
|
|
2238
|
-
if (eqFlag) {
|
|
2239
|
-
skillId = eqFlag.split('=').slice(1).join('=');
|
|
2240
|
-
} else {
|
|
2241
|
-
const sIdx = args.indexOf('-s');
|
|
2242
|
-
const longIdx = args.indexOf('--skill');
|
|
2243
|
-
const flagIdx = sIdx !== -1 ? sIdx : longIdx;
|
|
2244
|
-
if (flagIdx !== -1 && args[flagIdx + 1] && !String(args[flagIdx + 1]).startsWith('-')) {
|
|
2245
|
-
skillId = args[flagIdx + 1];
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
if (!skillId) {
|
|
2249
|
-
const positional = args[1];
|
|
2250
|
-
if (positional && !String(positional).startsWith('-')) skillId = positional;
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
if (!skillId) {
|
|
2254
|
-
console.error('Usage: evolver fetch --skill <skill_id>');
|
|
2255
|
-
console.error(' evolver fetch -s <skill_id>');
|
|
2256
|
-
process.exit(1);
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
|
|
2260
|
-
const { hubFetch } = require('./src/gep/hubFetch');
|
|
2261
|
-
|
|
2262
|
-
const hubUrl = getHubUrl();
|
|
2263
|
-
if (!hubUrl) {
|
|
2264
|
-
console.error('[fetch] A2A_HUB_URL is not configured.');
|
|
2265
|
-
console.error('Set it via environment variable or .env file:');
|
|
2266
|
-
console.error(' export A2A_HUB_URL=https://evomap.ai');
|
|
2267
|
-
process.exit(1);
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
try {
|
|
2271
|
-
if (!getHubNodeSecret()) {
|
|
2272
|
-
// Round-7 (§20.7): if a daemon is up and we have no secret, we
|
|
2273
|
-
// would race the daemon's hello and silently corrupt its
|
|
2274
|
-
// node_secret. Refuse cleanly with a hint instead.
|
|
2275
|
-
refuseHelloIfDaemonRunning('fetch');
|
|
2276
|
-
console.log('[fetch] No node_secret found. Sending hello to Hub to register...');
|
|
2277
|
-
const helloResult = await sendHelloToHub();
|
|
2278
|
-
if (!helloResult || !helloResult.ok) {
|
|
2279
|
-
console.error('[fetch] Failed to register with Hub:', helloResult && helloResult.error || 'unknown');
|
|
2280
|
-
process.exit(1);
|
|
2281
|
-
}
|
|
2282
|
-
console.log('[fetch] Registered as ' + getNodeId());
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
const endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/skill/store/' + encodeURIComponent(skillId) + '/download';
|
|
2286
|
-
const nodeId = getNodeId();
|
|
2287
|
-
|
|
2288
|
-
console.log('[fetch] Downloading skill: ' + skillId);
|
|
2289
|
-
|
|
2290
|
-
const resp = await hubFetch(endpoint, {
|
|
2291
|
-
method: 'POST',
|
|
2292
|
-
headers: buildHubHeaders(),
|
|
2293
|
-
body: JSON.stringify({ sender_id: nodeId }),
|
|
2294
|
-
signal: AbortSignal.timeout(30000),
|
|
2295
|
-
});
|
|
2296
|
-
|
|
2297
|
-
if (!resp.ok) {
|
|
2298
|
-
const body = await resp.text().catch(() => '');
|
|
2299
|
-
let errorDetail = '';
|
|
2300
|
-
let errorCode = '';
|
|
2301
|
-
try {
|
|
2302
|
-
const j = JSON.parse(body);
|
|
2303
|
-
errorDetail = j.detail || j.message || j.error || '';
|
|
2304
|
-
errorCode = j.error || j.code || '';
|
|
2305
|
-
} catch (_) {
|
|
2306
|
-
errorDetail = body ? body.slice(0, 500) : '';
|
|
2307
|
-
}
|
|
2308
|
-
console.error('[fetch] Download failed (HTTP ' + resp.status + ')' + (errorCode ? ': ' + errorCode : ''));
|
|
2309
|
-
if (errorDetail && errorDetail !== errorCode) {
|
|
2310
|
-
console.error(' Detail: ' + errorDetail);
|
|
2311
|
-
}
|
|
2312
|
-
if (resp.status === 404) {
|
|
2313
|
-
console.error(' Skill "' + skillId + '" not found or not publicly available.');
|
|
2314
|
-
console.error(' Check the skill ID spelling, or browse available skills at https://evomap.ai');
|
|
2315
|
-
} else if (resp.status === 401 || resp.status === 403) {
|
|
2316
|
-
console.error(' Authentication failed. Try:');
|
|
2317
|
-
console.error(' 1. Delete ~/.evomap/node_secret and retry');
|
|
2318
|
-
console.error(' 2. Re-register: set A2A_NODE_ID and run fetch again');
|
|
2319
|
-
} else if (resp.status === 402) {
|
|
2320
|
-
console.error(' Insufficient credits. Check your balance at https://evomap.ai');
|
|
2321
|
-
} else if (resp.status >= 500) {
|
|
2322
|
-
console.error(' Server error. The Hub may be temporarily unavailable.');
|
|
2323
|
-
console.error(' Try again in a few minutes. If the issue persists, report at:');
|
|
2324
|
-
console.error(' https://github.com/EvoMap/evolver/issues');
|
|
2325
|
-
}
|
|
2326
|
-
if (isVerbose) {
|
|
2327
|
-
console.error('[Verbose] Endpoint: ' + endpoint);
|
|
2328
|
-
console.error('[Verbose] Status: ' + resp.status + ' ' + (resp.statusText || ''));
|
|
2329
|
-
console.error('[Verbose] Response body: ' + (body || '(empty)').slice(0, 2000));
|
|
2330
|
-
}
|
|
2331
|
-
process.exit(1);
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
const data = await resp.json();
|
|
2335
|
-
const outFlag = args.find(a => typeof a === 'string' && a.startsWith('--out='));
|
|
2336
|
-
const safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
2337
|
-
// Reject safeId values that would either stay inside cwd instead of
|
|
2338
|
-
// descending into skills/, or escape cwd entirely. The sanitizing regex
|
|
2339
|
-
// above permits `.`, so `..` / `.` / empty survive it; `path.join('.',
|
|
2340
|
-
// 'skills', '..')` collapses to `.` which turns the download directory
|
|
2341
|
-
// into the user's working directory and lets Hub-supplied bundled_files
|
|
2342
|
-
// overwrite `index.js`, `package.json`, etc. See GHSA-cfcj-hqpf-hccf.
|
|
2343
|
-
if (
|
|
2344
|
-
safeId === '' ||
|
|
2345
|
-
safeId === '.' ||
|
|
2346
|
-
safeId === '..' ||
|
|
2347
|
-
safeId.includes('/') ||
|
|
2348
|
-
safeId.includes('\\') ||
|
|
2349
|
-
safeId.includes('\0')
|
|
2350
|
-
) {
|
|
2351
|
-
console.error('[fetch] Hub returned an invalid skill_id: ' + JSON.stringify(safeId));
|
|
2352
|
-
process.exit(1);
|
|
2353
|
-
}
|
|
2354
|
-
let outDir;
|
|
2355
|
-
if (outFlag) {
|
|
2356
|
-
const rawOut = outFlag.slice('--out='.length);
|
|
2357
|
-
if (!rawOut || rawOut.trim() === '') {
|
|
2358
|
-
console.error('[fetch] --out= value cannot be empty');
|
|
2359
|
-
process.exit(1);
|
|
2360
|
-
}
|
|
2361
|
-
const resolvedOut = path.resolve(process.cwd(), rawOut);
|
|
2362
|
-
const cwd = path.resolve(process.cwd());
|
|
2363
|
-
const rel = path.relative(cwd, resolvedOut);
|
|
2364
|
-
// Reject paths that escape the current working directory or are
|
|
2365
|
-
// absolute on a different volume/root. This prevents --out=../../etc
|
|
2366
|
-
// from writing outside the project tree.
|
|
2367
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
2368
|
-
console.error('[fetch] --out= must resolve to a path inside the current working directory');
|
|
2369
|
-
console.error(' Provided: ' + rawOut);
|
|
2370
|
-
console.error(' Resolved: ' + resolvedOut);
|
|
2371
|
-
console.error(' Workdir: ' + cwd);
|
|
2372
|
-
process.exit(1);
|
|
2373
|
-
}
|
|
2374
|
-
outDir = resolvedOut;
|
|
2375
|
-
} else {
|
|
2376
|
-
// Defense in depth: apply the same traversal check to the default
|
|
2377
|
-
// branch so any remaining path-smuggling shape in `safeId` is caught.
|
|
2378
|
-
const candidate = path.resolve(process.cwd(), 'skills', safeId);
|
|
2379
|
-
const skillsRoot = path.resolve(process.cwd(), 'skills');
|
|
2380
|
-
const rel = path.relative(skillsRoot, candidate);
|
|
2381
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
2382
|
-
console.error('[fetch] Hub-provided skill_id escapes skills/ directory: ' + JSON.stringify(safeId));
|
|
2383
|
-
process.exit(1);
|
|
2384
|
-
}
|
|
2385
|
-
outDir = candidate;
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
2389
|
-
|
|
2390
|
-
if (data.content) {
|
|
2391
|
-
fs.writeFileSync(path.join(outDir, 'SKILL.md'), data.content, 'utf8');
|
|
2392
|
-
}
|
|
2393
|
-
|
|
2394
|
-
const ALLOWED_SKILL_EXTENSIONS = new Set([
|
|
2395
|
-
'.js', '.mjs', '.cjs', '.ts',
|
|
2396
|
-
'.json', '.md', '.txt',
|
|
2397
|
-
'.sh', '.py',
|
|
2398
|
-
'.yml', '.yaml',
|
|
2399
|
-
]);
|
|
2400
|
-
const MAX_SKILL_FILE_BYTES = 512 * 1024;
|
|
2401
|
-
// Even with outDir locked to skills/, a legitimate-looking skill can
|
|
2402
|
-
// ship a bundled file named `package.json`, `index.js`, or any other
|
|
2403
|
-
// top-level project artifact whose name collides with something the
|
|
2404
|
-
// user may later copy back up. Prefix-guard the resolved path so every
|
|
2405
|
-
// write stays strictly within the resolved outDir (no trailing `/..`
|
|
2406
|
-
// in basename, no absolute path smuggling) and never points at cwd.
|
|
2407
|
-
const resolvedOutDir = path.resolve(outDir);
|
|
2408
|
-
const resolvedCwd = path.resolve(process.cwd());
|
|
2409
|
-
|
|
2410
|
-
const bundled = Array.isArray(data.bundled_files) ? data.bundled_files : [];
|
|
2411
|
-
const skippedFiles = [];
|
|
2412
|
-
for (const file of bundled) {
|
|
2413
|
-
if (!file || !file.name || typeof file.content !== 'string') continue;
|
|
2414
|
-
const safeName = path.basename(file.name);
|
|
2415
|
-
if (!safeName || safeName === '.' || safeName === '..') {
|
|
2416
|
-
skippedFiles.push(String(file.name));
|
|
2417
|
-
continue;
|
|
2418
|
-
}
|
|
2419
|
-
const ext = path.extname(safeName).toLowerCase();
|
|
2420
|
-
if (!ALLOWED_SKILL_EXTENSIONS.has(ext)) {
|
|
2421
|
-
console.warn('[fetch] Skipped skill file with disallowed extension: ' + safeName);
|
|
2422
|
-
skippedFiles.push(safeName);
|
|
2423
|
-
continue;
|
|
2424
|
-
}
|
|
2425
|
-
if (Buffer.byteLength(file.content, 'utf8') > MAX_SKILL_FILE_BYTES) {
|
|
2426
|
-
console.warn('[fetch] Skipped skill file exceeding ' + MAX_SKILL_FILE_BYTES + ' bytes: ' + safeName);
|
|
2427
|
-
skippedFiles.push(safeName);
|
|
2428
|
-
continue;
|
|
2429
|
-
}
|
|
2430
|
-
const destPath = path.resolve(resolvedOutDir, safeName);
|
|
2431
|
-
const relToOut = path.relative(resolvedOutDir, destPath);
|
|
2432
|
-
if (relToOut.startsWith('..') || path.isAbsolute(relToOut)) {
|
|
2433
|
-
console.warn('[fetch] Skipped bundled file whose resolved path escapes outDir: ' + safeName);
|
|
2434
|
-
skippedFiles.push(safeName);
|
|
2435
|
-
continue;
|
|
2436
|
-
}
|
|
2437
|
-
// Never let a bundled write touch the evolver's own cwd -- this is
|
|
2438
|
-
// the concrete attack shape from GHSA-cfcj-hqpf-hccf (fetch default
|
|
2439
|
-
// branch writing to `./index.js`). outDir should always be under
|
|
2440
|
-
// skills/ now, but belt-and-braces keep the guarantee explicit.
|
|
2441
|
-
if (path.dirname(destPath) === resolvedCwd) {
|
|
2442
|
-
console.warn('[fetch] Skipped bundled file that would land in cwd: ' + safeName);
|
|
2443
|
-
skippedFiles.push(safeName);
|
|
2444
|
-
continue;
|
|
2445
|
-
}
|
|
2446
|
-
fs.writeFileSync(destPath, file.content, 'utf8');
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
console.log('[fetch] Skill downloaded to: ' + outDir);
|
|
2450
|
-
console.log(' Name: ' + (data.name || skillId));
|
|
2451
|
-
console.log(' Version: ' + (data.version || '?'));
|
|
2452
|
-
console.log(' Files: SKILL.md' + (bundled.length > 0 ? ', ' + bundled.map(f => f.name).join(', ') : ''));
|
|
2453
|
-
if (data.already_purchased) {
|
|
2454
|
-
console.log(' Fetch cost: free (already purchased)');
|
|
2455
|
-
} else {
|
|
2456
|
-
console.log(' Fetch cost: ' + (data.credit_cost || 0) + ' credits');
|
|
2457
|
-
}
|
|
2458
|
-
} catch (error) {
|
|
2459
|
-
if (error && error.name === 'TimeoutError') {
|
|
2460
|
-
console.error('[fetch] Request timed out (30s). Check your network and A2A_HUB_URL.');
|
|
2461
|
-
console.error(' Hub URL: ' + hubUrl);
|
|
2462
|
-
} else {
|
|
2463
|
-
console.error('[fetch] Error: ' + (error && error.message || error));
|
|
2464
|
-
if (error && error.cause) console.error(' Cause: ' + (error.cause.message || error.cause.code || error.cause));
|
|
2465
|
-
if (isVerbose && error && error.stack) console.error('[Verbose] Stack:\n' + error.stack);
|
|
2466
|
-
}
|
|
2467
|
-
process.exit(1);
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
} else if (command === 'sync') {
|
|
2471
|
-
const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
|
|
2472
|
-
const { hubFetch } = require('./src/gep/hubFetch');
|
|
2473
|
-
const { upsertGene, upsertCapsule, loadGenes, loadCapsules } = require('./src/gep/assetStore');
|
|
2474
|
-
const { getGepAssetsDir, getMemoryDir } = require('./src/gep/paths');
|
|
2475
|
-
|
|
2476
|
-
const hubUrl = getHubUrl();
|
|
2477
|
-
if (!hubUrl) {
|
|
2478
|
-
console.error('[sync] A2A_HUB_URL is not configured.');
|
|
2479
|
-
process.exit(1);
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
try {
|
|
2483
|
-
if (!getHubNodeSecret()) {
|
|
2484
|
-
// Round-7 (§20.7): refuse a fresh hello if a live daemon owns
|
|
2485
|
-
// the lock; the daemon's secret will appear shortly.
|
|
2486
|
-
refuseHelloIfDaemonRunning('sync');
|
|
2487
|
-
console.log('[sync] No node_secret found. Sending hello to Hub to register...');
|
|
2488
|
-
const helloResult = await sendHelloToHub();
|
|
2489
|
-
if (!helloResult || !helloResult.ok) {
|
|
2490
|
-
console.error('[sync] Failed to register with Hub:', helloResult && helloResult.error || 'unknown');
|
|
2491
|
-
process.exit(1);
|
|
2492
|
-
}
|
|
2493
|
-
console.log('[sync] Registered as ' + getNodeId());
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
const nodeId = getNodeId();
|
|
2497
|
-
const baseUrl = hubUrl.replace(/\/+$/, '');
|
|
2498
|
-
const typeFilter = (function () {
|
|
2499
|
-
const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--type='); });
|
|
2500
|
-
return f ? f.slice('--type='.length) : null;
|
|
2501
|
-
})();
|
|
2502
|
-
const scopeArg = (function () {
|
|
2503
|
-
const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--scope='); });
|
|
2504
|
-
return f ? f.slice('--scope='.length) : 'all';
|
|
2505
|
-
})();
|
|
2506
|
-
const statusFilter = (function () {
|
|
2507
|
-
const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--status='); });
|
|
2508
|
-
return f ? f.slice('--status='.length) : null;
|
|
2509
|
-
})();
|
|
2510
|
-
const exportPath = (function () {
|
|
2511
|
-
const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--export='); });
|
|
2512
|
-
return f ? f.slice('--export='.length) : null;
|
|
2513
|
-
})();
|
|
2514
|
-
const dryRun = args.includes('--dry-run');
|
|
2515
|
-
const listUnpublished = !args.includes('--no-unpublished-list');
|
|
2516
|
-
const force = args.includes('--force');
|
|
2517
|
-
const limitPerPage = 100;
|
|
2518
|
-
|
|
2519
|
-
const validScopes = new Set(['all', 'purchased', 'published']);
|
|
2520
|
-
if (!validScopes.has(scopeArg)) {
|
|
2521
|
-
console.error('[sync] Invalid --scope=' + scopeArg + '. Expected: all, purchased, published.');
|
|
2522
|
-
process.exit(1);
|
|
2523
|
-
}
|
|
2524
|
-
const doPurchased = scopeArg === 'all' || scopeArg === 'purchased';
|
|
2525
|
-
const doPublished = scopeArg === 'all' || scopeArg === 'published';
|
|
2526
|
-
|
|
2527
|
-
async function fetchAllPages(endpoint, extraParams) {
|
|
2528
|
-
const out = [];
|
|
2529
|
-
let cursor = null;
|
|
2530
|
-
let page = 0;
|
|
2531
|
-
while (true) {
|
|
2532
|
-
page++;
|
|
2533
|
-
let url = baseUrl + endpoint + '?node_id=' + encodeURIComponent(nodeId) + '&limit=' + limitPerPage;
|
|
2534
|
-
if (cursor) url += '&cursor=' + encodeURIComponent(cursor);
|
|
2535
|
-
if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter);
|
|
2536
|
-
if (extraParams) {
|
|
2537
|
-
for (const [k, v] of Object.entries(extraParams)) {
|
|
2538
|
-
if (v != null) url += '&' + k + '=' + encodeURIComponent(v);
|
|
2539
|
-
}
|
|
2540
|
-
}
|
|
2541
|
-
const resp = await hubFetch(url, {
|
|
2542
|
-
method: 'GET',
|
|
2543
|
-
headers: buildHubHeaders(),
|
|
2544
|
-
signal: AbortSignal.timeout(30000),
|
|
2545
|
-
});
|
|
2546
|
-
if (!resp.ok) {
|
|
2547
|
-
const body = await resp.text().catch(function () { return ''; });
|
|
2548
|
-
throw new Error('Hub HTTP ' + resp.status + ' on ' + endpoint + ': ' + body.slice(0, 500));
|
|
2549
|
-
}
|
|
2550
|
-
const data = await resp.json();
|
|
2551
|
-
if (Array.isArray(data.assets)) out.push.apply(out, data.assets);
|
|
2552
|
-
if (isVerbose) console.log('[sync] ' + endpoint + ' page ' + page + ': ' + (data.count || 0) + ' (total ' + out.length + ')');
|
|
2553
|
-
if (data.has_more && data.next_cursor) cursor = data.next_cursor;
|
|
2554
|
-
else break;
|
|
2555
|
-
}
|
|
2556
|
-
return out;
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
let purchasedAssets = [];
|
|
2560
|
-
let publishedAssets = [];
|
|
2561
|
-
|
|
2562
|
-
if (doPurchased) {
|
|
2563
|
-
console.log('[sync] Fetching purchased assets from Hub...');
|
|
2564
|
-
purchasedAssets = await fetchAllPages('/a2a/assets/purchased');
|
|
2565
|
-
console.log('[sync] purchased: ' + purchasedAssets.length + ' asset(s)');
|
|
2566
|
-
}
|
|
2567
|
-
if (doPublished) {
|
|
2568
|
-
console.log('[sync] Fetching published-by-me assets from Hub (includes drafts)...');
|
|
2569
|
-
publishedAssets = await fetchAllPages('/a2a/assets/published-by-me', { status: statusFilter });
|
|
2570
|
-
console.log('[sync] published: ' + publishedAssets.length + ' asset(s)');
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
const seen = new Set();
|
|
2574
|
-
const allAssets = [];
|
|
2575
|
-
for (const src of [purchasedAssets, publishedAssets]) {
|
|
2576
|
-
for (const asset of src) {
|
|
2577
|
-
if (!asset || !asset.asset_id) continue;
|
|
2578
|
-
if (seen.has(asset.asset_id)) continue;
|
|
2579
|
-
seen.add(asset.asset_id);
|
|
2580
|
-
allAssets.push(asset);
|
|
2581
|
-
}
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
if (allAssets.length === 0) {
|
|
2585
|
-
console.log('[sync] No remote assets to sync.');
|
|
2586
|
-
if (!exportPath && !(listUnpublished && doPublished)) {
|
|
2587
|
-
process.exit(0);
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
const existingGenes = loadGenes();
|
|
2592
|
-
const existingCapsules = loadCapsules();
|
|
2593
|
-
// Dedup by Hub asset_id is the only safe key. Local-facing `id` (e.g.
|
|
2594
|
-
// `gene_gep_repair_from_errors`) collides between bundled default seed
|
|
2595
|
-
// genes and identically-named assets that the user later published, so
|
|
2596
|
-
// dedup-by-id silently skips legitimate Hub copies on first sync. Track
|
|
2597
|
-
// hub_asset_id (set by previous syncs / publishes) and only skip when
|
|
2598
|
-
// we've already seen the same Hub-side identity.
|
|
2599
|
-
const localHubAssetIds = new Set();
|
|
2600
|
-
for (const g of existingGenes) {
|
|
2601
|
-
if (g && g.hub_asset_id) localHubAssetIds.add(String(g.hub_asset_id));
|
|
2602
|
-
}
|
|
2603
|
-
for (const c of existingCapsules) {
|
|
2604
|
-
if (c && c.hub_asset_id) localHubAssetIds.add(String(c.hub_asset_id));
|
|
2605
|
-
}
|
|
2606
|
-
const localGeneIds = new Set(existingGenes.filter(function (g) { return g && g.id; }).map(function (g) { return g.id; }));
|
|
2607
|
-
const localCapsuleIds = new Set(existingCapsules.filter(function (c) { return c && c.id; }).map(function (c) { return c.id; }));
|
|
2608
|
-
|
|
2609
|
-
let synced = 0;
|
|
2610
|
-
let skippedAlreadySynced = 0;
|
|
2611
|
-
let skippedIdCollision = 0;
|
|
2612
|
-
let fetchErrors = 0;
|
|
2613
|
-
|
|
2614
|
-
for (const asset of allAssets) {
|
|
2615
|
-
const assetId = asset.asset_id;
|
|
2616
|
-
const assetType = asset.asset_type;
|
|
2617
|
-
const localId = asset.local_id || assetId;
|
|
2618
|
-
|
|
2619
|
-
if (assetType !== 'Gene' && assetType !== 'Capsule') {
|
|
2620
|
-
skippedAlreadySynced++;
|
|
2621
|
-
continue;
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
// Already-synced check: same Hub asset_id is already in our local
|
|
2625
|
-
// store. Idempotent skip; safe to no-op even with --force because
|
|
2626
|
-
// re-fetching the same payload would only rewrite identical bytes.
|
|
2627
|
-
if (!force && localHubAssetIds.has(String(assetId))) {
|
|
2628
|
-
skippedAlreadySynced++;
|
|
2629
|
-
continue;
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
// Local-id collision: a local entry with the same user-facing id
|
|
2633
|
-
// already exists but has no hub_asset_id (e.g. bundled default seed
|
|
2634
|
-
// gene, or a hand-edited entry). Without --force we keep the
|
|
2635
|
-
// user-owned entry and warn so the user can decide.
|
|
2636
|
-
if (!force) {
|
|
2637
|
-
if (assetType === 'Gene' && localGeneIds.has(localId)) {
|
|
2638
|
-
if (isVerbose) console.warn(' [sync] Skipping ' + localId + ' (local id collision; pass --force to overwrite with Hub copy)');
|
|
2639
|
-
skippedIdCollision++;
|
|
2640
|
-
continue;
|
|
2641
|
-
}
|
|
2642
|
-
if (assetType === 'Capsule' && localCapsuleIds.has(localId)) {
|
|
2643
|
-
if (isVerbose) console.warn(' [sync] Skipping ' + localId + ' (local id collision; pass --force to overwrite with Hub copy)');
|
|
2644
|
-
skippedIdCollision++;
|
|
2645
|
-
continue;
|
|
2646
|
-
}
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
if (dryRun) {
|
|
2650
|
-
console.log(' [dry-run] Would sync: ' + assetType + ' ' + assetId + (force ? ' (force)' : ''));
|
|
2651
|
-
synced++;
|
|
2652
|
-
continue;
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
try {
|
|
2656
|
-
let payload = asset.payload;
|
|
2657
|
-
if (!payload) {
|
|
2658
|
-
const detailResp = await hubFetch(baseUrl + '/a2a/assets/' + encodeURIComponent(assetId) + '?detailed=true', {
|
|
2659
|
-
method: 'GET',
|
|
2660
|
-
headers: buildHubHeaders(),
|
|
2661
|
-
signal: AbortSignal.timeout(15000),
|
|
2662
|
-
});
|
|
2663
|
-
if (!detailResp.ok) {
|
|
2664
|
-
if (isVerbose) console.warn(' [sync] Failed to fetch detail for ' + assetId + ' (HTTP ' + detailResp.status + ')');
|
|
2665
|
-
fetchErrors++;
|
|
2666
|
-
continue;
|
|
2667
|
-
}
|
|
2668
|
-
const detail = await detailResp.json();
|
|
2669
|
-
payload = detail.payload || {};
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
if (assetType === 'Gene') {
|
|
2673
|
-
const geneObj = {
|
|
2674
|
-
type: 'Gene',
|
|
2675
|
-
id: payload.id || localId,
|
|
2676
|
-
category: payload.category || 'unknown',
|
|
2677
|
-
signals: Array.isArray(payload.signals) ? payload.signals : [],
|
|
2678
|
-
strategy: Array.isArray(payload.strategy) ? payload.strategy : [],
|
|
2679
|
-
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
|
|
2680
|
-
validation: payload.validation || {},
|
|
2681
|
-
summary: payload.summary || asset.summary || '',
|
|
2682
|
-
hub_asset_id: assetId,
|
|
2683
|
-
synced_at: new Date().toISOString(),
|
|
2684
|
-
};
|
|
2685
|
-
upsertGene(geneObj);
|
|
2686
|
-
localGeneIds.add(geneObj.id);
|
|
2687
|
-
localHubAssetIds.add(String(assetId));
|
|
2688
|
-
} else {
|
|
2689
|
-
const capsuleObj = {
|
|
2690
|
-
type: 'Capsule',
|
|
2691
|
-
id: payload.id || localId,
|
|
2692
|
-
gene: payload.gene || null,
|
|
2693
|
-
genes_used: Array.isArray(payload.genes_used) ? payload.genes_used : [],
|
|
2694
|
-
outcome: payload.outcome || {},
|
|
2695
|
-
execution_trace: payload.execution_trace || {},
|
|
2696
|
-
summary: payload.summary || asset.summary || '',
|
|
2697
|
-
hub_asset_id: assetId,
|
|
2698
|
-
synced_at: new Date().toISOString(),
|
|
2699
|
-
};
|
|
2700
|
-
upsertCapsule(capsuleObj);
|
|
2701
|
-
localCapsuleIds.add(capsuleObj.id);
|
|
2702
|
-
localHubAssetIds.add(String(assetId));
|
|
2703
|
-
}
|
|
2704
|
-
synced++;
|
|
2705
|
-
} catch (fetchErr) {
|
|
2706
|
-
if (isVerbose) console.warn(' [sync] Error fetching ' + assetId + ': ' + (fetchErr && fetchErr.message || fetchErr));
|
|
2707
|
-
fetchErrors++;
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
const skippedTotal = skippedAlreadySynced + skippedIdCollision;
|
|
2712
|
-
console.log('[sync] Done. scope=' + scopeArg + ' synced=' + synced + ' skipped=' + skippedTotal + ' (already_synced=' + skippedAlreadySynced + ', id_collision=' + skippedIdCollision + ') errors=' + fetchErrors);
|
|
2713
|
-
if (skippedIdCollision > 0 && !force) {
|
|
2714
|
-
console.log('[sync] ' + skippedIdCollision + ' Hub asset(s) share a local id with an existing local entry that has no hub_asset_id.');
|
|
2715
|
-
console.log('[sync] Re-run with --force to overwrite those local entries with the Hub copies.');
|
|
2716
|
-
}
|
|
2717
|
-
if (dryRun) console.log('[sync] (dry-run mode: no files were modified)');
|
|
2718
|
-
|
|
2719
|
-
if (listUnpublished && doPublished) {
|
|
2720
|
-
const hubGeneIds = new Set();
|
|
2721
|
-
const hubCapsuleIds = new Set();
|
|
2722
|
-
for (const a of publishedAssets) {
|
|
2723
|
-
const lid = a.local_id || a.asset_id;
|
|
2724
|
-
if (a.asset_type === 'Gene') hubGeneIds.add(lid);
|
|
2725
|
-
else if (a.asset_type === 'Capsule') hubCapsuleIds.add(lid);
|
|
2726
|
-
}
|
|
2727
|
-
const unpublishedGenes = existingGenes.filter(function (g) {
|
|
2728
|
-
return g && g.id && !hubGeneIds.has(g.id) && !g.hub_asset_id;
|
|
2729
|
-
});
|
|
2730
|
-
const unpublishedCapsules = existingCapsules.filter(function (c) {
|
|
2731
|
-
return c && c.id && !hubCapsuleIds.has(c.id) && !c.hub_asset_id;
|
|
2732
|
-
});
|
|
2733
|
-
if (unpublishedGenes.length || unpublishedCapsules.length) {
|
|
2734
|
-
console.log('[sync] Local-only (not on Hub): genes=' + unpublishedGenes.length + ' capsules=' + unpublishedCapsules.length);
|
|
2735
|
-
if (isVerbose) {
|
|
2736
|
-
for (const g of unpublishedGenes.slice(0, 20)) console.log(' gene: ' + g.id);
|
|
2737
|
-
for (const c of unpublishedCapsules.slice(0, 20)) console.log(' capsule: ' + c.id);
|
|
2738
|
-
if (unpublishedGenes.length + unpublishedCapsules.length > 40) {
|
|
2739
|
-
console.log(' ... (truncated; use --export=<path>.gepx to bundle all)');
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
}
|
|
2744
|
-
|
|
2745
|
-
if (exportPath) {
|
|
2746
|
-
if (dryRun) {
|
|
2747
|
-
console.log('[sync] [dry-run] Would export to ' + exportPath);
|
|
2748
|
-
} else {
|
|
2749
|
-
const { exportGepx } = require('./src/gep/portable');
|
|
2750
|
-
const assetsDir = getGepAssetsDir();
|
|
2751
|
-
const memoryGraphPath = require('path').join(getMemoryDir(), 'memory_graph.jsonl');
|
|
2752
|
-
try {
|
|
2753
|
-
const result = exportGepx({
|
|
2754
|
-
assetsDir,
|
|
2755
|
-
memoryGraphPath,
|
|
2756
|
-
outputPath: exportPath,
|
|
2757
|
-
agentId: nodeId,
|
|
2758
|
-
agentName: process.env.AGENT_NAME || 'evolver',
|
|
2759
|
-
});
|
|
2760
|
-
console.log('[sync] Exported .gepx -> ' + result.outputPath);
|
|
2761
|
-
console.log('[sync] stats: ' + JSON.stringify(result.manifest.statistics));
|
|
2762
|
-
} catch (exportErr) {
|
|
2763
|
-
console.error('[sync] Export failed: ' + (exportErr && exportErr.message || exportErr));
|
|
2764
|
-
process.exit(1);
|
|
2765
|
-
}
|
|
2766
|
-
}
|
|
2767
|
-
}
|
|
2768
|
-
} catch (error) {
|
|
2769
|
-
if (error && error.name === 'TimeoutError') {
|
|
2770
|
-
console.error('[sync] Request timed out. Check your network and A2A_HUB_URL.');
|
|
2771
|
-
} else {
|
|
2772
|
-
console.error('[sync] Error: ' + (error && error.message || error));
|
|
2773
|
-
}
|
|
2774
|
-
process.exit(1);
|
|
2775
|
-
}
|
|
2776
|
-
|
|
2777
|
-
} else if (command === 'asset-log') {
|
|
2778
|
-
const { summarizeCallLog, readCallLog, getLogPath } = require('./src/gep/assetCallLog');
|
|
2779
|
-
|
|
2780
|
-
const runIdFlag = args.find(a => typeof a === 'string' && a.startsWith('--run='));
|
|
2781
|
-
const actionFlag = args.find(a => typeof a === 'string' && a.startsWith('--action='));
|
|
2782
|
-
const lastFlag = args.find(a => typeof a === 'string' && a.startsWith('--last='));
|
|
2783
|
-
const sinceFlag = args.find(a => typeof a === 'string' && a.startsWith('--since='));
|
|
2784
|
-
const jsonMode = args.includes('--json');
|
|
2785
|
-
|
|
2786
|
-
const opts = {};
|
|
2787
|
-
if (runIdFlag) opts.run_id = runIdFlag.slice('--run='.length);
|
|
2788
|
-
if (actionFlag) opts.action = actionFlag.slice('--action='.length);
|
|
2789
|
-
if (lastFlag) opts.last = parseInt(lastFlag.slice('--last='.length), 10);
|
|
2790
|
-
if (sinceFlag) opts.since = sinceFlag.slice('--since='.length);
|
|
2791
|
-
|
|
2792
|
-
if (jsonMode) {
|
|
2793
|
-
const entries = readCallLog(opts);
|
|
2794
|
-
console.log(JSON.stringify(entries, null, 2));
|
|
2795
|
-
} else {
|
|
2796
|
-
const summary = summarizeCallLog(opts);
|
|
2797
|
-
console.log(`\n[Asset Call Log] ${getLogPath()}`);
|
|
2798
|
-
console.log(` Total entries: ${summary.total_entries}`);
|
|
2799
|
-
console.log(` Unique assets: ${summary.unique_assets}`);
|
|
2800
|
-
console.log(` Unique runs: ${summary.unique_runs}`);
|
|
2801
|
-
console.log(` By action:`);
|
|
2802
|
-
for (const [action, count] of Object.entries(summary.by_action)) {
|
|
2803
|
-
console.log(` ${action}: ${count}`);
|
|
2804
|
-
}
|
|
2805
|
-
if (summary.entries.length > 0) {
|
|
2806
|
-
console.log(`\n Recent entries:`);
|
|
2807
|
-
const show = summary.entries.slice(-10);
|
|
2808
|
-
for (const e of show) {
|
|
2809
|
-
const ts = e.timestamp ? e.timestamp.slice(0, 19) : '?';
|
|
2810
|
-
const assetShort = e.asset_id ? e.asset_id.slice(0, 20) + '...' : '(none)';
|
|
2811
|
-
const sigPreview = Array.isArray(e.signals) ? e.signals.slice(0, 3).join(', ') : '';
|
|
2812
|
-
console.log(` [${ts}] ${e.action || '?'} asset=${assetShort} score=${e.score || '-'} mode=${e.mode || '-'} signals=[${sigPreview}] run=${e.run_id || '-'}`);
|
|
2813
|
-
}
|
|
2814
|
-
} else {
|
|
2815
|
-
console.log('\n No entries found.');
|
|
2816
|
-
}
|
|
2817
|
-
console.log('');
|
|
2818
|
-
}
|
|
2819
|
-
|
|
2820
|
-
} else if (command === 'trajectory-export') {
|
|
2821
|
-
const allowPartial = args.includes('--allow-partial');
|
|
2822
|
-
const runtimeSessions = args.includes('--runtime-sessions');
|
|
2823
|
-
// Strict-by-default marking gate (v1): runtime-session discovery only
|
|
2824
|
-
// collects sessions evolver actively marked (session-start hook), minus
|
|
2825
|
-
// those the gateway already captured. These flags open each gate back up.
|
|
2826
|
-
const includeUnmarked = args.includes('--include-unmarked');
|
|
2827
|
-
const includeGatewayCaptured = args.includes('--include-gateway-captured');
|
|
2828
|
-
try {
|
|
2829
|
-
const optionValue = (name) => {
|
|
2830
|
-
let value;
|
|
2831
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
2832
|
-
const arg = args[i];
|
|
2833
|
-
if (arg === name) {
|
|
2834
|
-
if (i + 1 >= args.length || String(args[i + 1]).startsWith('--')) {
|
|
2835
|
-
throw new Error('missing value for ' + name);
|
|
2836
|
-
}
|
|
2837
|
-
value = String(args[i + 1]);
|
|
2838
|
-
} else if (typeof arg === 'string' && arg.startsWith(name + '=')) {
|
|
2839
|
-
value = arg.slice(name.length + 1);
|
|
2840
|
-
}
|
|
2841
|
-
}
|
|
2842
|
-
return value;
|
|
2843
|
-
};
|
|
2844
|
-
const optionValues = (name) => {
|
|
2845
|
-
const values = [];
|
|
2846
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
2847
|
-
const arg = args[i];
|
|
2848
|
-
if (arg === name) {
|
|
2849
|
-
if (i + 1 >= args.length || String(args[i + 1]).startsWith('--')) {
|
|
2850
|
-
throw new Error('missing value for ' + name);
|
|
2851
|
-
}
|
|
2852
|
-
values.push(String(args[i + 1]));
|
|
2853
|
-
} else if (typeof arg === 'string' && arg.startsWith(name + '=')) {
|
|
2854
|
-
values.push(arg.slice(name.length + 1));
|
|
2855
|
-
}
|
|
2856
|
-
}
|
|
2857
|
-
return values;
|
|
2858
|
-
};
|
|
2859
|
-
const input = optionValue('--input');
|
|
2860
|
-
const output = optionValue('--output');
|
|
2861
|
-
const runtimeSessionDirs = optionValues('--runtime-session-dir');
|
|
2862
|
-
const nodeSecretInline = optionValue('--node-secret');
|
|
2863
|
-
const nodeSecretFile = optionValue('--node-secret-file');
|
|
2864
|
-
const nodeSecretEnv = optionValue('--node-secret-env');
|
|
2865
|
-
const hubPrivateKeyFile = optionValue('--hub-private-key');
|
|
2866
|
-
const nodeSecretKeyringFile = optionValue('--node-secret-keyring');
|
|
2867
|
-
const markedSessionsFile = optionValue('--marked-sessions-file');
|
|
2868
|
-
let nodeSecret;
|
|
2869
|
-
let hubPrivateKey;
|
|
2870
|
-
let nodeSecretKeyring;
|
|
2871
|
-
const nodeSecretSourceCount = [nodeSecretInline, nodeSecretFile, nodeSecretEnv]
|
|
2872
|
-
.filter((value) => value !== undefined).length;
|
|
2873
|
-
if (nodeSecretSourceCount > 1) {
|
|
2874
|
-
throw new Error('use only one node secret source');
|
|
2875
|
-
}
|
|
2876
|
-
if (nodeSecretInline !== undefined) {
|
|
2877
|
-
if (!nodeSecretInline) throw new Error('missing value for --node-secret');
|
|
2878
|
-
if (fs.existsSync(nodeSecretInline)) {
|
|
2879
|
-
try {
|
|
2880
|
-
nodeSecret = fs.readFileSync(nodeSecretInline, 'utf8').trim();
|
|
2881
|
-
} catch (readErr) {
|
|
2882
|
-
const err = new Error('node secret file is not readable: ' + nodeSecretInline);
|
|
2883
|
-
err.cause = readErr;
|
|
2884
|
-
throw err;
|
|
2885
|
-
}
|
|
2886
|
-
if (!nodeSecret) throw new Error('node secret file is empty');
|
|
2887
|
-
} else {
|
|
2888
|
-
nodeSecret = String(nodeSecretInline).trim();
|
|
2889
|
-
if (!nodeSecret) throw new Error('node secret is empty');
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
|
-
if (nodeSecretFile !== undefined) {
|
|
2893
|
-
if (!nodeSecretFile) throw new Error('missing path for --node-secret-file');
|
|
2894
|
-
try {
|
|
2895
|
-
nodeSecret = fs.readFileSync(nodeSecretFile, 'utf8').trim();
|
|
2896
|
-
} catch (readErr) {
|
|
2897
|
-
const err = new Error('node secret file is not readable: ' + nodeSecretFile);
|
|
2898
|
-
err.cause = readErr;
|
|
2899
|
-
throw err;
|
|
2900
|
-
}
|
|
2901
|
-
if (!nodeSecret) throw new Error('node secret file is empty');
|
|
2902
|
-
}
|
|
2903
|
-
if (nodeSecretEnv !== undefined) {
|
|
2904
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(nodeSecretEnv)) {
|
|
2905
|
-
throw new Error('invalid environment variable name for --node-secret-env');
|
|
2906
|
-
}
|
|
2907
|
-
nodeSecret = String(process.env[nodeSecretEnv] || '').trim();
|
|
2908
|
-
if (!nodeSecret) throw new Error('node secret environment variable is empty or unset');
|
|
2909
|
-
}
|
|
2910
|
-
if (hubPrivateKeyFile !== undefined) {
|
|
2911
|
-
if (!hubPrivateKeyFile) throw new Error('missing path for --hub-private-key');
|
|
2912
|
-
try {
|
|
2913
|
-
hubPrivateKey = fs.readFileSync(hubPrivateKeyFile, 'utf8');
|
|
2914
|
-
} catch (readErr) {
|
|
2915
|
-
const err = new Error('hub private key file is not readable: ' + hubPrivateKeyFile);
|
|
2916
|
-
err.cause = readErr;
|
|
2917
|
-
throw err;
|
|
2918
|
-
}
|
|
2919
|
-
if (!String(hubPrivateKey || '').trim()) throw new Error('hub private key file is empty');
|
|
2920
|
-
}
|
|
2921
|
-
if (nodeSecretKeyringFile !== undefined) {
|
|
2922
|
-
if (!nodeSecretKeyringFile) throw new Error('missing path for --node-secret-keyring');
|
|
2923
|
-
try {
|
|
2924
|
-
nodeSecretKeyring = JSON.parse(fs.readFileSync(nodeSecretKeyringFile, 'utf8'));
|
|
2925
|
-
} catch (readErr) {
|
|
2926
|
-
const err = new Error('node secret keyring file is not readable or invalid JSON: ' + nodeSecretKeyringFile);
|
|
2927
|
-
err.cause = readErr;
|
|
2928
|
-
throw err;
|
|
2929
|
-
}
|
|
2930
|
-
}
|
|
2931
|
-
const { writeTrajectories } = require('./src/gep/trajectoryExport');
|
|
2932
|
-
const result = writeTrajectories({
|
|
2933
|
-
input,
|
|
2934
|
-
output,
|
|
2935
|
-
nodeSecret,
|
|
2936
|
-
nodeSecretKeyring,
|
|
2937
|
-
hubPrivateKey,
|
|
2938
|
-
allowPartial,
|
|
2939
|
-
runtimeSessions: runtimeSessions ? true : undefined,
|
|
2940
|
-
runtimeSessionDirs,
|
|
2941
|
-
markedSessionsFile,
|
|
2942
|
-
includeUnmarked: includeUnmarked ? true : undefined,
|
|
2943
|
-
includeGatewayCaptured: includeGatewayCaptured ? true : undefined,
|
|
2944
|
-
});
|
|
2945
|
-
console.log('[trajectory-export] Wrote ' + result.trajectories.length + ' trajector' + (result.trajectories.length === 1 ? 'y' : 'ies') + ' to ' + result.outputPath);
|
|
2946
|
-
console.log('[trajectory-export] Read ' + result.rowsRead + ' trace row(s) and ' + (result.sessionTurnsRead || 0) + ' session turn(s) from ' + (result.filesRead || 0) + ' file(s).');
|
|
2947
|
-
if (result.sessionFilesRead > 0) {
|
|
2948
|
-
console.log('[trajectory-export] Converted ' + result.sessionFilesRead + ' runtime session file(s).');
|
|
2949
|
-
}
|
|
2950
|
-
if (result.runtimeSessionDiscovery && result.runtimeSessionDiscovery.enabled) {
|
|
2951
|
-
console.log('[trajectory-export] Runtime session discovery is local-only; no Hub upload is performed.');
|
|
2952
|
-
console.log('[trajectory-export] Runtime discovery scanned '
|
|
2953
|
-
+ result.runtimeSessionDiscovery.dirsScanned + ' Codex/Claude dir(s), matched '
|
|
2954
|
-
+ result.runtimeSessionDiscovery.filesMatched + ' workspace session file(s).');
|
|
2955
|
-
const mg = result.runtimeSessionDiscovery.markGate;
|
|
2956
|
-
if (mg) {
|
|
2957
|
-
console.log('[trajectory-export] Marking gate: enforce_marked=' + mg.enforceMarked
|
|
2958
|
-
+ ', exclude_gateway_captured=' + mg.excludeGatewayCaptured
|
|
2959
|
-
+ '; marked_registry=' + mg.markedSessionCount
|
|
2960
|
-
+ ', gateway_captured=' + mg.gatewayCapturedCount
|
|
2961
|
-
+ ', excluded_unmarked=' + mg.excludedByMark
|
|
2962
|
-
+ ', excluded_gateway=' + mg.excludedByGateway + '.');
|
|
2963
|
-
}
|
|
2964
|
-
}
|
|
2965
|
-
if (result.stats) {
|
|
2966
|
-
console.log('[trajectory-export] Scanned ' + result.stats.rowsScanned
|
|
2967
|
-
+ ' row(s); encrypted=' + result.stats.encryptedRows
|
|
2968
|
-
+ ', skipped_missing_secret=' + result.stats.skippedMissingSecret
|
|
2969
|
-
+ ', decrypt_failures=' + result.stats.decryptFailures
|
|
2970
|
-
+ ', invalid_json=' + result.stats.invalidJson
|
|
2971
|
-
+ ', session_invalid_json=' + (result.stats.sessionInvalidJson || 0)
|
|
2972
|
-
+ ', non_prism=' + result.stats.nonPrismSkipped + '.');
|
|
2973
|
-
}
|
|
2974
|
-
} catch (error) {
|
|
2975
|
-
console.error('[trajectory-export] Failed: ' + (error && error.message || error));
|
|
2976
|
-
process.exit(1);
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
} else if (command === 'webui') {
|
|
2980
|
-
const portFlag = args.find(a => typeof a === 'string' && a.startsWith('--port='));
|
|
2981
|
-
const port = portFlag ? Number(portFlag.slice('--port='.length)) : undefined;
|
|
2982
|
-
const { startWebUi } = require('./src/webui');
|
|
2983
|
-
try {
|
|
2984
|
-
const info = await startWebUi({ port });
|
|
2985
|
-
console.log('[webui] Open ' + info.url);
|
|
2986
|
-
const shutdown = async () => {
|
|
2987
|
-
try { await info.server.stop(); } catch (_) {}
|
|
2988
|
-
process.exit(0);
|
|
2989
|
-
};
|
|
2990
|
-
process.on('SIGINT', shutdown);
|
|
2991
|
-
process.on('SIGTERM', shutdown);
|
|
2992
|
-
await new Promise(() => {});
|
|
2993
|
-
} catch (error) {
|
|
2994
|
-
console.error('[webui] Failed: ' + (error && error.message || error));
|
|
2995
|
-
process.exit(1);
|
|
2996
|
-
}
|
|
2997
|
-
|
|
2998
|
-
} else if (command === 'login') {
|
|
2999
|
-
const { deviceLogin, resolveHubUrl, tokenFile } = require('./src/gep/oauthLogin');
|
|
3000
|
-
const hubUrl = resolveHubUrl();
|
|
3001
|
-
try {
|
|
3002
|
-
console.log('Logging in to ' + hubUrl + ' ...');
|
|
3003
|
-
const tok = await deviceLogin({
|
|
3004
|
-
hubUrl,
|
|
3005
|
-
onCode: ({ userCode, verificationUri }) => {
|
|
3006
|
-
console.log('\nTo authorize this device:');
|
|
3007
|
-
console.log(' 1. open ' + verificationUri);
|
|
3008
|
-
console.log(' 2. enter code: ' + userCode);
|
|
3009
|
-
console.log('\nWaiting for approval (Ctrl-C to cancel)...');
|
|
3010
|
-
},
|
|
3011
|
-
});
|
|
3012
|
-
console.log('\n✓ Logged in. Token stored at ' + tokenFile() + ' (expires ' + new Date(tok.expires_at).toISOString() + ').');
|
|
3013
|
-
process.exit(0);
|
|
3014
|
-
} catch (error) {
|
|
3015
|
-
console.error('login failed: ' + (error && error.message || error));
|
|
3016
|
-
process.exit(1);
|
|
3017
|
-
}
|
|
3018
|
-
|
|
3019
|
-
} else if (command === 'logout') {
|
|
3020
|
-
const { clearOAuthToken, tokenFile } = require('./src/gep/oauthLogin');
|
|
3021
|
-
const removed = clearOAuthToken();
|
|
3022
|
-
console.log(removed ? ('Logged out (removed ' + tokenFile() + ').') : 'No OAuth token to remove.');
|
|
3023
|
-
process.exit(0);
|
|
3024
|
-
|
|
3025
|
-
} else if (command === 'setup-hooks') {
|
|
3026
|
-
const hookAdapter = require('./src/adapters/hookAdapter');
|
|
3027
|
-
const { setupHooks, resolveConfigRoot, detectPlatform, loadAdapter } = hookAdapter;
|
|
3028
|
-
|
|
3029
|
-
const platformFlag = args.find(a => typeof a === 'string' && a.startsWith('--platform='));
|
|
3030
|
-
const platform = platformFlag ? platformFlag.slice('--platform='.length) : undefined;
|
|
3031
|
-
const force = args.includes('--force');
|
|
3032
|
-
const uninstall = args.includes('--uninstall');
|
|
3033
|
-
const verifyOnly = args.includes('--verify');
|
|
3034
|
-
|
|
3035
|
-
if (verifyOnly) {
|
|
3036
|
-
// Read-only verification: do not touch any files, just report whether
|
|
3037
|
-
// the previously-installed hooks/plugin look healthy. Lets users answer
|
|
3038
|
-
// "is the plugin actually loaded?" without grepping opencode logs.
|
|
3039
|
-
try {
|
|
3040
|
-
const platformId = platform || detectPlatform(process.cwd());
|
|
3041
|
-
if (!platformId) {
|
|
3042
|
-
console.error('[setup-hooks] --verify: could not detect platform. Pass --platform=opencode|cursor|claude-code|codex|kiro');
|
|
3043
|
-
process.exit(2);
|
|
3044
|
-
}
|
|
3045
|
-
const adapter = loadAdapter(platformId);
|
|
3046
|
-
if (!adapter || typeof adapter.verify !== 'function') {
|
|
3047
|
-
console.error('[setup-hooks] --verify: platform ' + platformId + ' does not support verification yet.');
|
|
3048
|
-
process.exit(2);
|
|
3049
|
-
}
|
|
3050
|
-
const configRoot = resolveConfigRoot(platformId, process.cwd());
|
|
3051
|
-
const report = adapter.verify({ configRoot });
|
|
3052
|
-
if (typeof adapter.printVerifyReport === 'function') {
|
|
3053
|
-
adapter.printVerifyReport(report);
|
|
3054
|
-
} else {
|
|
3055
|
-
console.log(JSON.stringify(report, null, 2));
|
|
3056
|
-
}
|
|
3057
|
-
process.exit(report.ok ? 0 : 1);
|
|
3058
|
-
} catch (verifyErr) {
|
|
3059
|
-
console.error('[setup-hooks] --verify error:', verifyErr && verifyErr.message || verifyErr);
|
|
3060
|
-
process.exit(1);
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
try {
|
|
3065
|
-
const result = await setupHooks({
|
|
3066
|
-
platform,
|
|
3067
|
-
cwd: process.cwd(),
|
|
3068
|
-
force,
|
|
3069
|
-
uninstall,
|
|
3070
|
-
evolverRoot: __dirname,
|
|
3071
|
-
});
|
|
3072
|
-
if (result && result.ok) {
|
|
3073
|
-
if (!uninstall && result.files) {
|
|
3074
|
-
console.log('\n[setup-hooks] Files created/updated:');
|
|
3075
|
-
for (const f of result.files) {
|
|
3076
|
-
console.log(' ' + f);
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
process.exit(0);
|
|
3080
|
-
} else {
|
|
3081
|
-
console.error('[setup-hooks] Failed: ' + (result && result.error || 'unknown'));
|
|
3082
|
-
process.exit(1);
|
|
3083
|
-
}
|
|
3084
|
-
} catch (error) {
|
|
3085
|
-
console.error('[setup-hooks] Error:', error && error.message || error);
|
|
3086
|
-
process.exit(1);
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
|
-
} else if (command === 'reset-local-secret') {
|
|
3090
|
-
// Wipe every local store of node_secret in one shot, so a daemon stuck
|
|
3091
|
-
// after a manual web reset (https://evomap.ai/account -> Reset Secret)
|
|
3092
|
-
// can boot clean. Local stores involved:
|
|
3093
|
-
// - MailboxStore: ~/.evomap/mailbox/state.json node_secret state keys
|
|
3094
|
-
// - Legacy files: ~/.evomap/node_secret, node_secret_version,
|
|
3095
|
-
// node_secret_source, and node_secret_env_suppressed
|
|
3096
|
-
// - Shell env: A2A_NODE_SECRET / EVOMAP_NODE_SECRET and matching
|
|
3097
|
-
// version vars (we cannot mutate the parent shell; we
|
|
3098
|
-
// just print the unset hint)
|
|
3099
|
-
const path = require('path');
|
|
3100
|
-
const fs = require('fs');
|
|
3101
|
-
// Honor an explicit HOME override (used by tests to redirect to a fake
|
|
3102
|
-
// home) before falling back to os.homedir(). On POSIX, os.homedir() also
|
|
3103
|
-
// reads $HOME first, so this is a no-op in practice on macOS/Linux. On
|
|
3104
|
-
// Windows, os.homedir() reads %USERPROFILE% and ignores HOME -- without
|
|
3105
|
-
// this fallback, test/resetLocalSecret.test.js cannot inject a fake home
|
|
3106
|
-
// and the reset operates on the real user dir.
|
|
3107
|
-
const home = process.env.HOME || os.homedir();
|
|
3108
|
-
const evomapDirs = [];
|
|
3109
|
-
const seenEvomapDirs = new Set();
|
|
3110
|
-
const addEvomapDir = (dir) => {
|
|
3111
|
-
if (!dir) return;
|
|
3112
|
-
const resolved = path.resolve(dir);
|
|
3113
|
-
if (seenEvomapDirs.has(resolved)) return;
|
|
3114
|
-
seenEvomapDirs.add(resolved);
|
|
3115
|
-
evomapDirs.push(dir);
|
|
3116
|
-
};
|
|
3117
|
-
addEvomapDir(path.join(home, '.evomap'));
|
|
3118
|
-
addEvomapDir(process.env.EVOLVER_HOME);
|
|
3119
|
-
let cleared = 0;
|
|
3120
|
-
try {
|
|
3121
|
-
for (const evomapDir of evomapDirs) {
|
|
3122
|
-
const stateFile = path.join(evomapDir, 'mailbox', 'state.json');
|
|
3123
|
-
if (fs.existsSync(stateFile)) {
|
|
3124
|
-
const raw = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
3125
|
-
let mutated = false;
|
|
3126
|
-
for (const k of ['node_secret', 'node_secret_source', 'node_secret_version', 'node_secret_env_suppressed']) {
|
|
3127
|
-
if (raw[k] !== undefined && raw[k] !== '') {
|
|
3128
|
-
raw[k] = '';
|
|
3129
|
-
mutated = true;
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
3132
|
-
if (mutated) {
|
|
3133
|
-
fs.writeFileSync(stateFile, JSON.stringify(raw, null, 2) + '\n', 'utf8');
|
|
3134
|
-
cleared += 1;
|
|
3135
|
-
console.log('[reset-local-secret] cleared MailboxStore at ' + stateFile);
|
|
3136
|
-
} else {
|
|
3137
|
-
console.log('[reset-local-secret] MailboxStore had no node_secret to clear at ' + stateFile);
|
|
3138
|
-
}
|
|
3139
|
-
}
|
|
3140
|
-
for (const legacyName of ['node_secret', 'node_secret_version', 'node_secret_source', 'node_secret_env_suppressed']) {
|
|
3141
|
-
const legacyFile = path.join(evomapDir, legacyName);
|
|
3142
|
-
if (fs.existsSync(legacyFile)) {
|
|
3143
|
-
fs.unlinkSync(legacyFile);
|
|
3144
|
-
cleared += 1;
|
|
3145
|
-
console.log('[reset-local-secret] removed legacy file ' + legacyFile);
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
|
-
}
|
|
3149
|
-
} catch (err) {
|
|
3150
|
-
console.error('[reset-local-secret] error:', err && err.message || err);
|
|
3151
|
-
process.exit(1);
|
|
3152
|
-
}
|
|
3153
|
-
const stillSetEnv = [
|
|
3154
|
-
'A2A_NODE_SECRET',
|
|
3155
|
-
'A2A_NODE_SECRET_VERSION',
|
|
3156
|
-
'EVOMAP_NODE_SECRET',
|
|
3157
|
-
'EVOMAP_NODE_SECRET_VERSION',
|
|
3158
|
-
].filter((key) => process.env[key]);
|
|
3159
|
-
if (stillSetEnv.length > 0) {
|
|
3160
|
-
console.log('');
|
|
3161
|
-
console.log('[reset-local-secret] Node secret env vars are still set in this shell: ' + stillSetEnv.join(', '));
|
|
3162
|
-
console.log('[reset-local-secret] Run: unset A2A_NODE_SECRET A2A_NODE_SECRET_VERSION EVOMAP_NODE_SECRET EVOMAP_NODE_SECRET_VERSION');
|
|
3163
|
-
console.log('[reset-local-secret] Or update secret and version as a matched pair before restarting the daemon.');
|
|
3164
|
-
} else {
|
|
3165
|
-
console.log('[reset-local-secret] Node secret env vars are not set in env -- good.');
|
|
3166
|
-
}
|
|
3167
|
-
console.log('[reset-local-secret] ' + cleared + ' location(s) cleared. Restart the daemon to pick a fresh secret from the hub.');
|
|
3168
|
-
process.exit(0);
|
|
3169
|
-
|
|
3170
|
-
} else if (command === 'atp-complete') {
|
|
3171
|
-
// Invoked by a spawned Cursor sub-session after it has written the ATP
|
|
3172
|
-
// task answer to a file. Drives publish -> task/complete -> atp/deliver.
|
|
3173
|
-
try {
|
|
3174
|
-
// Round-8 (§21.8): if a daemon is up and the spawned subsession
|
|
3175
|
-
// somehow has no secret on disk, the inner completeAtpTask ->
|
|
3176
|
-
// _ensureNodeSecret -> sendHelloToHub call would race the
|
|
3177
|
-
// daemon's hello and silently corrupt the daemon's node_secret
|
|
3178
|
-
// (same vector round-7 §20.7 closed for fetch/sync). In the
|
|
3179
|
-
// common happy path the daemon already registered, the secret
|
|
3180
|
-
// exists, the guard is a no-op. Imported lazily so the helper
|
|
3181
|
-
// resolution does not slow down unrelated subcommands.
|
|
3182
|
-
try {
|
|
3183
|
-
const { getHubNodeSecret } = require('./src/gep/a2aProtocol');
|
|
3184
|
-
if (!getHubNodeSecret()) refuseHelloIfDaemonRunning('atp-complete');
|
|
3185
|
-
} catch (_) { /* never block ATP completion on a guard error */ }
|
|
3186
|
-
const subArgs = args.slice(1);
|
|
3187
|
-
function flag(name) {
|
|
3188
|
-
const pref = '--' + name + '=';
|
|
3189
|
-
const hit = subArgs.find(function (a) { return typeof a === 'string' && a.startsWith(pref); });
|
|
3190
|
-
return hit ? hit.slice(pref.length) : null;
|
|
3191
|
-
}
|
|
3192
|
-
function list(name) {
|
|
3193
|
-
const raw = flag(name);
|
|
3194
|
-
if (!raw) return null;
|
|
3195
|
-
return raw.split(',').map(function (s) { return String(s).trim(); }).filter(Boolean);
|
|
3196
|
-
}
|
|
3197
|
-
const taskId = flag('task-id');
|
|
3198
|
-
const orderId = flag('order-id');
|
|
3199
|
-
const answerFile = flag('answer-file');
|
|
3200
|
-
const summary = flag('summary');
|
|
3201
|
-
const capabilities = list('capabilities');
|
|
3202
|
-
const signals = list('signals');
|
|
3203
|
-
if (!taskId || !orderId || !answerFile) {
|
|
3204
|
-
console.error('[ATP-Complete] Missing required flags: --task-id, --order-id, --answer-file');
|
|
3205
|
-
console.error('Usage: node index.js atp-complete --task-id=<tid> --order-id=<oid> --answer-file=<path> [--summary="..."] [--capabilities=cap1,cap2] [--signals=sig1,sig2]');
|
|
3206
|
-
process.exit(2);
|
|
3207
|
-
}
|
|
3208
|
-
const { completeAtpTask } = require('./src/atp/atpExecute');
|
|
3209
|
-
const res = await completeAtpTask({ taskId, orderId, answerFile, summary, capabilities, signals });
|
|
3210
|
-
if (res && res.ok) {
|
|
3211
|
-
console.log('[ATP-Complete] OK asset_id=' + res.assetId + (res.deliveryId ? ' delivery_id=' + res.deliveryId : ''));
|
|
3212
|
-
process.exit(0);
|
|
3213
|
-
}
|
|
3214
|
-
console.error('[ATP-Complete] FAILED stage=' + (res && res.stage) + ' error=' + (res && res.error));
|
|
3215
|
-
process.exit(1);
|
|
3216
|
-
} catch (atpCompleteErr) {
|
|
3217
|
-
console.error('[ATP-Complete] Error:', atpCompleteErr && atpCompleteErr.message || atpCompleteErr);
|
|
3218
|
-
process.exit(1);
|
|
3219
|
-
}
|
|
3220
|
-
|
|
3221
|
-
} else if (command === 'buy' || command === 'orders' || command === 'verify' || command === 'atp') {
|
|
3222
|
-
try {
|
|
3223
|
-
// Round-8 (§21.8): same daemon-vs-CLI race protection as fetch/sync
|
|
3224
|
-
// and atp-complete. The ATP runners (consumerAgent / merchantAgent
|
|
3225
|
-
// / atpExecute) all call sendHelloToHub when getHubNodeSecret() is
|
|
3226
|
-
// empty, which clobbers a running daemon's secret and silences it
|
|
3227
|
-
// for 30 min..4 h. The check is a no-op when a secret already
|
|
3228
|
-
// exists (the common case once the daemon has registered).
|
|
3229
|
-
try {
|
|
3230
|
-
const { getHubNodeSecret } = require('./src/gep/a2aProtocol');
|
|
3231
|
-
if (!getHubNodeSecret()) refuseHelloIfDaemonRunning(command);
|
|
3232
|
-
} catch (_) { /* never block ATP CLI on a guard error */ }
|
|
3233
|
-
const atpCli = require('./src/atp/cli');
|
|
3234
|
-
const subArgs = args.slice(1); // drop the command token (e.g. "buy") itself
|
|
3235
|
-
let parsed;
|
|
3236
|
-
let runner;
|
|
3237
|
-
if (command === 'buy') {
|
|
3238
|
-
parsed = atpCli.parseBuyArgs(subArgs);
|
|
3239
|
-
runner = atpCli.runBuy;
|
|
3240
|
-
} else if (command === 'orders') {
|
|
3241
|
-
parsed = atpCli.parseOrdersArgs(subArgs);
|
|
3242
|
-
runner = atpCli.runOrders;
|
|
3243
|
-
} else if (command === 'verify') {
|
|
3244
|
-
parsed = atpCli.parseVerifyArgs(subArgs);
|
|
3245
|
-
runner = atpCli.runVerify;
|
|
3246
|
-
} else {
|
|
3247
|
-
parsed = atpCli.parseAtpArgs(subArgs);
|
|
3248
|
-
runner = atpCli.runAtp;
|
|
3249
|
-
}
|
|
3250
|
-
if (!parsed.ok) {
|
|
3251
|
-
console.error('[ATP] ' + parsed.error);
|
|
3252
|
-
console.error(atpCli.printUsage());
|
|
3253
|
-
process.exit(2);
|
|
3254
|
-
}
|
|
3255
|
-
const res = await runner(parsed.opts);
|
|
3256
|
-
process.exit(res && typeof res.exitCode === 'number' ? res.exitCode : 0);
|
|
3257
|
-
} catch (atpCliErr) {
|
|
3258
|
-
console.error('[ATP] CLI error:', atpCliErr && atpCliErr.message || atpCliErr);
|
|
3259
|
-
process.exit(1);
|
|
3260
|
-
}
|
|
3261
|
-
|
|
3262
|
-
} else if (command === 'reuse') {
|
|
3263
|
-
try {
|
|
3264
|
-
const { runReuseCommand } = require('./src/gep/cliContracts');
|
|
3265
|
-
process.exit(await runReuseCommand(args.slice(1)));
|
|
3266
|
-
} catch (e) {
|
|
3267
|
-
process.stdout.write(JSON.stringify({
|
|
3268
|
-
ok: false,
|
|
3269
|
-
contract: 'reuse.v1',
|
|
3270
|
-
reason: 'internal_error',
|
|
3271
|
-
message: 'evolver reuse failed',
|
|
3272
|
-
}) + '\n');
|
|
3273
|
-
process.exit(1);
|
|
3274
|
-
}
|
|
3275
|
-
|
|
3276
|
-
} else if (command === 'publish') {
|
|
3277
|
-
try {
|
|
3278
|
-
const { runPublishCommand } = require('./src/gep/cliContracts');
|
|
3279
|
-
process.exit(await runPublishCommand(args.slice(1)));
|
|
3280
|
-
} catch (e) {
|
|
3281
|
-
process.stdout.write(JSON.stringify({
|
|
3282
|
-
ok: false,
|
|
3283
|
-
contract: 'publish.v1',
|
|
3284
|
-
reason: 'internal_error',
|
|
3285
|
-
retryable: false,
|
|
3286
|
-
message: 'evolver publish failed',
|
|
3287
|
-
}) + '\n');
|
|
3288
|
-
process.exit(1);
|
|
3289
|
-
}
|
|
3290
|
-
|
|
3291
|
-
} else if (command === 'recipe') {
|
|
3292
|
-
// recipe build — assemble a DNA blueprint from owned Gene/Capsule assets
|
|
3293
|
-
// recipe reuse — fetch + express an existing recipe into an organism
|
|
3294
|
-
const sub = args[1];
|
|
3295
|
-
const {
|
|
3296
|
-
getHubUrl, getNodeId, getHubNodeSecret, sendHelloToHub, rotateNodeSecret,
|
|
3297
|
-
hubCreateRecipe, hubPublishRecipe, hubGetRecipe, hubExpressRecipe,
|
|
3298
|
-
} = require('./src/gep/a2aProtocol');
|
|
3299
|
-
|
|
3300
|
-
const hubUrl = getHubUrl();
|
|
3301
|
-
if (!hubUrl) {
|
|
3302
|
-
console.error('[recipe] A2A_HUB_URL is not configured. Set A2A_HUB_URL (e.g. https://evomap.ai).');
|
|
3303
|
-
process.exit(1);
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
function flagVal(name) {
|
|
3307
|
-
const eq = args.find(a => typeof a === 'string' && a.startsWith('--' + name + '='));
|
|
3308
|
-
return eq ? eq.split('=').slice(1).join('=') : null;
|
|
3309
|
-
}
|
|
3310
|
-
async function ensureRegistered(tag) {
|
|
3311
|
-
if (!getHubNodeSecret()) {
|
|
3312
|
-
console.log('[' + tag + '] No node_secret found. Registering with Hub...');
|
|
3313
|
-
const hello = await sendHelloToHub();
|
|
3314
|
-
if (!hello || !hello.ok) {
|
|
3315
|
-
console.error('[' + tag + '] Failed to register with Hub:', (hello && hello.error) || 'unknown');
|
|
3316
|
-
process.exit(1);
|
|
3317
|
-
}
|
|
3318
|
-
console.log('[' + tag + '] Registered as ' + getNodeId());
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
// True when the hub rejected our node_secret as stale/invalid — the one
|
|
3322
|
-
// case where a rotate-and-retry is the documented recovery.
|
|
3323
|
-
function isStaleSecret(result) {
|
|
3324
|
-
if (!result || result.ok) return false;
|
|
3325
|
-
if (result.status !== 401 && result.status !== 403) return false;
|
|
3326
|
-
const e = String(result.error || '');
|
|
3327
|
-
return e.includes('node_secret_invalid') || e.includes('node_secret_not_set');
|
|
3328
|
-
}
|
|
3329
|
-
// Run a hub call; if it fails because our node_secret is stale, rotate
|
|
3330
|
-
// once and retry. Rotation only works when the CURRENT secret is still
|
|
3331
|
-
// server-valid (the hub authenticates the rotate with it). If the secret
|
|
3332
|
-
// has fully diverged from the server, rotation cannot recover it — that
|
|
3333
|
-
// requires re-registering, so we surface the actionable recovery path
|
|
3334
|
-
// instead of silently looping.
|
|
3335
|
-
let _authRecoveryFailed = false;
|
|
3336
|
-
async function callWithAuthRetry(tag, fn) {
|
|
3337
|
-
let result = await fn();
|
|
3338
|
-
if (isStaleSecret(result) && typeof rotateNodeSecret === 'function' && !_authRecoveryFailed) {
|
|
3339
|
-
console.log('[' + tag + '] node_secret stale; rotating via /a2a/hello and retrying...');
|
|
3340
|
-
const rot = await rotateNodeSecret();
|
|
3341
|
-
if (rot && rot.ok) {
|
|
3342
|
-
result = await fn();
|
|
3343
|
-
} else {
|
|
3344
|
-
_authRecoveryFailed = true;
|
|
3345
|
-
console.error('[' + tag + '] Could not auto-rotate: the local node_secret has diverged from the Hub and can no longer authenticate a rotate.');
|
|
3346
|
-
console.error(' Recover by either:');
|
|
3347
|
-
console.error(' 1. Reset Secret on the web (Account -> Reset Secret), then run: node index.js reset-local-secret');
|
|
3348
|
-
console.error(' 2. Or register a fresh node: set a new A2A_NODE_ID and retry (auto-provisions).');
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
return result;
|
|
3352
|
-
}
|
|
3353
|
-
function reportHubError(tag, result) {
|
|
3354
|
-
console.error('[' + tag + '] Hub call failed' + (result.status ? ' (HTTP ' + result.status + ')' : '') + ': ' + (result.error || 'unknown'));
|
|
3355
|
-
if (result.status === 401 || result.status === 403) console.error(' Auth failed. If this persists, delete ~/.evomap/node_secret and retry.');
|
|
3356
|
-
else if (result.status === 402) console.error(' Insufficient credits. Check your balance at ' + hubUrl);
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
if (sub === 'build') {
|
|
3360
|
-
// --genes=<asset_id,...> ordered; types resolved from the local asset store.
|
|
3361
|
-
const genesArg = flagVal('genes');
|
|
3362
|
-
const title = flagVal('title');
|
|
3363
|
-
const description = flagVal('description');
|
|
3364
|
-
const doPublish = args.includes('--publish');
|
|
3365
|
-
if (!genesArg || !title) {
|
|
3366
|
-
console.error('Usage: node index.js recipe build --title="..." --genes=<asset_id,...> [--description="..."] [--price=N] [--publish]');
|
|
3367
|
-
console.error(' Builds a DRAFT recipe by default. --publish is opt-in and pushes it live.');
|
|
3368
|
-
process.exit(1);
|
|
3369
|
-
}
|
|
3370
|
-
const { loadGenes, loadCapsules } = require('./src/gep/assetStore');
|
|
3371
|
-
const typeById = new Map();
|
|
3372
|
-
try {
|
|
3373
|
-
for (const g of (loadGenes() || [])) if (g && g.asset_id) typeById.set(g.asset_id, 'Gene');
|
|
3374
|
-
for (const c of (loadCapsules() || [])) if (c && c.asset_id) typeById.set(c.asset_id, 'Capsule');
|
|
3375
|
-
} catch (e) { /* fall back to Gene below */ }
|
|
3376
|
-
|
|
3377
|
-
const ids = genesArg.split(',').map(s => s.trim()).filter(Boolean);
|
|
3378
|
-
if (ids.length === 0) { console.error('[recipe build] --genes is empty.'); process.exit(1); }
|
|
3379
|
-
if (ids.length > 20) { console.error('[recipe build] at most 20 steps per recipe.'); process.exit(1); }
|
|
3380
|
-
const steps = ids.map((asset_id, i) => ({
|
|
3381
|
-
asset_id,
|
|
3382
|
-
asset_type: typeById.get(asset_id) || 'Gene',
|
|
3383
|
-
position: i,
|
|
3384
|
-
}));
|
|
3385
|
-
|
|
3386
|
-
await ensureRegistered('recipe build');
|
|
3387
|
-
const priceVal = flagVal('price');
|
|
3388
|
-
const createRes = await callWithAuthRetry('recipe build', () => hubCreateRecipe({
|
|
3389
|
-
title, steps, description: description || undefined,
|
|
3390
|
-
pricePerExecution: priceVal ? Number(priceVal) : undefined,
|
|
3391
|
-
}));
|
|
3392
|
-
if (!createRes.ok) { reportHubError('recipe build', createRes); process.exit(1); }
|
|
3393
|
-
const recipe = (createRes.data && (createRes.data.recipe || createRes.data)) || {};
|
|
3394
|
-
const recipeId = recipe.id;
|
|
3395
|
-
console.log('[recipe build] Created DRAFT recipe ' + recipeId + ' ("' + title + '", ' + steps.length + ' steps).');
|
|
3396
|
-
|
|
3397
|
-
if (doPublish && recipeId) {
|
|
3398
|
-
const pubRes = await callWithAuthRetry('recipe build', () => hubPublishRecipe(recipeId));
|
|
3399
|
-
if (!pubRes.ok) { reportHubError('recipe build', pubRes); process.exit(1); }
|
|
3400
|
-
console.log('[recipe build] Published recipe ' + recipeId + ' to the marketplace.');
|
|
3401
|
-
} else if (!doPublish) {
|
|
3402
|
-
console.log('[recipe build] Left as draft. Re-run with --publish to make it live.');
|
|
3403
|
-
}
|
|
3404
|
-
process.exit(0);
|
|
3405
|
-
} else if (sub === 'reuse') {
|
|
3406
|
-
const recipeId = flagVal('id') || (args[2] && !String(args[2]).startsWith('-') ? args[2] : null);
|
|
3407
|
-
if (!recipeId) {
|
|
3408
|
-
console.error('Usage: node index.js recipe reuse --id=<recipe_id> [--input=<json>]');
|
|
3409
|
-
process.exit(1);
|
|
3410
|
-
}
|
|
3411
|
-
await ensureRegistered('recipe reuse');
|
|
3412
|
-
const getRes = await hubGetRecipe(recipeId);
|
|
3413
|
-
if (!getRes.ok) { reportHubError('recipe reuse', getRes); process.exit(1); }
|
|
3414
|
-
let inputPayload = {};
|
|
3415
|
-
const inputArg = flagVal('input');
|
|
3416
|
-
if (inputArg) {
|
|
3417
|
-
try { inputPayload = JSON.parse(inputArg); }
|
|
3418
|
-
catch (e) { console.error('[recipe reuse] --input must be valid JSON.'); process.exit(1); }
|
|
3419
|
-
}
|
|
3420
|
-
const expRes = await callWithAuthRetry('recipe reuse', () => hubExpressRecipe(recipeId, inputPayload));
|
|
3421
|
-
if (!expRes.ok) { reportHubError('recipe reuse', expRes); process.exit(1); }
|
|
3422
|
-
console.log('[recipe reuse] Expressed recipe ' + recipeId + '.');
|
|
3423
|
-
if (isVerbose) console.log(JSON.stringify(expRes.data, null, 2));
|
|
3424
|
-
process.exit(0);
|
|
3425
|
-
} else {
|
|
3426
|
-
console.error('Usage: node index.js recipe <build|reuse> [flags]');
|
|
3427
|
-
console.error(' build --title="..." --genes=<asset_id,...> [--publish] (draft unless --publish)');
|
|
3428
|
-
console.error(' reuse --id=<recipe_id> [--input=<json>]');
|
|
3429
|
-
process.exit(1);
|
|
3430
|
-
}
|
|
3431
|
-
|
|
3432
|
-
} else if (command === 'experiment') {
|
|
3433
|
-
// Comparative experiment runner: run the SAME task twice -- a baseline arm
|
|
3434
|
-
// and a variant arm that reuses a gene's strategy -- via a headless agent
|
|
3435
|
-
// CLI, collect duration/rounds/tokens/pass-rate, and print a comparison
|
|
3436
|
-
// JSON to stdout. Consumed by EvoMap Desktop's ExperimentsAPI.Run, which
|
|
3437
|
-
// spawns `node index.js experiment --request-file=<json>` and parses stdout.
|
|
3438
|
-
try {
|
|
3439
|
-
const expCli = require('./src/experiment/cli');
|
|
3440
|
-
const parsed = expCli.parseExperimentArgs(args.slice(1));
|
|
3441
|
-
if (!parsed.ok) {
|
|
3442
|
-
console.error('[Experiment] ' + parsed.error);
|
|
3443
|
-
console.error(expCli.printExperimentUsage());
|
|
3444
|
-
process.exit(2);
|
|
3445
|
-
}
|
|
3446
|
-
const res = await expCli.runExperiment(parsed.opts, { err: (...a) => console.error(...a) });
|
|
3447
|
-
// stdout carries ONLY the structured JSON so the Go caller can JSON.parse
|
|
3448
|
-
// it without log contamination; all logging above went to stderr. res.data
|
|
3449
|
-
// is already secret-redacted by runExperiment (sanitizePayload).
|
|
3450
|
-
if (res && res.data) process.stdout.write(JSON.stringify(res.data) + '\n');
|
|
3451
|
-
process.exit(res && typeof res.exitCode === 'number' ? res.exitCode : (res && res.ok ? 0 : 1));
|
|
3452
|
-
} catch (expErr) {
|
|
3453
|
-
console.error('[Experiment] CLI error:', expErr && expErr.message || expErr);
|
|
3454
|
-
process.exit(1);
|
|
3455
|
-
}
|
|
3456
|
-
|
|
3457
|
-
} else {
|
|
3458
|
-
console.log(`Usage: node index.js [run|/evolve|login|logout|proxy-token|solidify|review|distill|fetch|sync|asset-log|trajectory-export|webui|setup-hooks|reuse|publish|recipe|buy|orders|verify|atp|atp-complete|experiment] [--loop]
|
|
3459
|
-
- login (authorize this device via the hub, gh-auth-login style; stores an OAuth token used instead of node_secret)
|
|
3460
|
-
- logout (remove the stored OAuth token)
|
|
3461
|
-
- proxy-token (print the local proxy bearer token for command-backed client auth)
|
|
3462
|
-
- experiment flags:
|
|
3463
|
-
- --task="..." --metric="..." (required; same task, baseline vs variant)
|
|
3464
|
-
- --gene=<geneId> (variant arm reuses this gene's strategy)
|
|
3465
|
-
- --baseline="..." --variant="..." --validation="c1;;c2" --request-file=<json>
|
|
3466
|
-
- recipe flags:
|
|
3467
|
-
- build --title="..." --genes=<asset_id,...> [--description] [--price=N] [--publish]
|
|
3468
|
-
(builds a DRAFT DNA blueprint; --publish is opt-in)
|
|
3469
|
-
- reuse --id=<recipe_id> [--input=<json>] (express a recipe into an organism)
|
|
3470
|
-
- reuse flags:
|
|
3471
|
-
- --id=<asset_id> --json (reuse a Hub asset into the local library; stdout JSON contract reuse.v1)
|
|
3472
|
-
- publish flags:
|
|
3473
|
-
- --asset=<id|path> [--asset ...] [--dry-run] --json
|
|
3474
|
-
(publish.v1 stdout JSON contract for Desktop)
|
|
3475
|
-
- fetch flags:
|
|
3476
|
-
- --skill=<id> | -s <id> (skill ID to download)
|
|
3477
|
-
- --out=<dir> (output directory, default: ./skills/<skill_id>)
|
|
3478
|
-
- sync flags:
|
|
3479
|
-
- --scope=all|purchased|published (default: all)
|
|
3480
|
-
- --type=Gene|Capsule (filter by asset type)
|
|
3481
|
-
- --status=draft,promoted,all (only for published scope; default promoted+draft)
|
|
3482
|
-
- --export=<path.gepx> (also bundle local assets into a .gepx archive)
|
|
3483
|
-
- --no-unpublished-list (suppress local-only asset list)
|
|
3484
|
-
- --force (overwrite local entries that share an id with a Hub asset; bypasses default-seed dedup)
|
|
3485
|
-
- --dry-run (preview without writing to local store)
|
|
3486
|
-
- solidify flags:
|
|
3487
|
-
- --dry-run
|
|
3488
|
-
- --no-rollback
|
|
3489
|
-
- --intent=repair|optimize|innovate
|
|
3490
|
-
- --summary=...
|
|
3491
|
-
- review flags:
|
|
3492
|
-
- --approve (approve and solidify the pending changes)
|
|
3493
|
-
- --reject (reject and rollback the pending changes)
|
|
3494
|
-
- distill flags:
|
|
3495
|
-
- --response-file=<path> (LLM response file for skill distillation)
|
|
3496
|
-
- setup-hooks flags:
|
|
3497
|
-
- --platform=cursor|claude-code|codex|kiro|opencode (auto-detect if omitted)
|
|
3498
|
-
- --force (overwrite existing config)
|
|
3499
|
-
- --uninstall (remove evolver hooks)
|
|
3500
|
-
- --verify (read-only: print install health for the chosen platform)
|
|
3501
|
-
- asset-log flags:
|
|
3502
|
-
- --run=<run_id> (filter by run ID)
|
|
3503
|
-
- --action=<action> (filter: hub_search_hit, hub_search_miss, asset_reuse, asset_reference, asset_publish, asset_publish_skip)
|
|
3504
|
-
- --last=<N> (show last N entries)
|
|
3505
|
-
- --since=<ISO_date> (entries after date)
|
|
3506
|
-
- --json (raw JSON output)
|
|
3507
|
-
- trajectory-export flags:
|
|
3508
|
-
- --input=<path>|--input <path>
|
|
3509
|
-
(proxy trace JSONL, Claude/Codex/Cursor session JSONL, or directory; default: platform trace file)
|
|
3510
|
-
- --output=<path>|--output <path>
|
|
3511
|
-
(output JSONL; default: ./coding-trajectories.jsonl)
|
|
3512
|
-
- --runtime-sessions (local-only: also scan current-workspace Codex/Claude JSONL from ~/.codex/sessions and ~/.claude/projects; env: EVOLVER_TRAJECTORY_RUNTIME_SESSIONS=1)
|
|
3513
|
-
- --runtime-session-dir=<path>|--runtime-session-dir <path>
|
|
3514
|
-
(local-only: extra Codex/Claude runtime session directory; may be repeated)
|
|
3515
|
-
- --include-unmarked (collect runtime sessions even if evolver did not actively mark them; default: strict — only marked sessions are collected; env: EVOLVER_TRAJECTORY_INCLUDE_UNMARKED=1)
|
|
3516
|
-
- --include-gateway-captured (collect runtime sessions even if the proxy gateway already captured them; default: strict — gateway-captured sessions are skipped; env: EVOLVER_TRAJECTORY_INCLUDE_GATEWAY_CAPTURED=1)
|
|
3517
|
-
- --marked-sessions-file=<path>|--marked-sessions-file <path>
|
|
3518
|
-
(override the evolver-marked-sessions registry path; default: <trace-dir>/marked-sessions.jsonl; env: EVOLVER_MARKED_SESSIONS_FILE)
|
|
3519
|
-
- --allow-partial (skip unreadable encrypted rows instead of failing the whole export)
|
|
3520
|
-
- --node-secret=<hex|path>|--node-secret <hex|path>
|
|
3521
|
-
(read node secret from an existing local file, otherwise use the literal value)
|
|
3522
|
-
- --node-secret-file=<path>|--node-secret-file <path>
|
|
3523
|
-
(read node secret from a local file)
|
|
3524
|
-
- --node-secret-env=<name>|--node-secret-env <name>
|
|
3525
|
-
(read node secret from an environment variable)
|
|
3526
|
-
- --node-secret-keyring=<path>|--node-secret-keyring <path>
|
|
3527
|
-
(read versioned node secrets from a JSON file)
|
|
3528
|
-
- --hub-private-key=<path>|--hub-private-key <path>
|
|
3529
|
-
(decrypt hub_key_envelope trace rows with a local private key)
|
|
3530
|
-
- webui flags:
|
|
3531
|
-
- --port=<N> (local Web UI port, default 19821)
|
|
3532
|
-
|
|
3533
|
-
ATP (Agent Transaction Protocol) subcommands:
|
|
3534
|
-
- buy <caps> (place an ATP order; caps is comma-separated)
|
|
3535
|
-
- --budget=<N> (credits to spend, default 10)
|
|
3536
|
-
- --question="..." (order description)
|
|
3537
|
-
- --routing=<mode> (fastest|cheapest|auction|swarm, default fastest)
|
|
3538
|
-
- --verify=<mode> (auto|ai_judge|bilateral, default auto)
|
|
3539
|
-
- --no-wait (return immediately after placing)
|
|
3540
|
-
- --timeout=<seconds> (lifecycle timeout, default 300)
|
|
3541
|
-
- orders (list your recent ATP orders / deliveries)
|
|
3542
|
-
- --role=consumer|merchant (default consumer)
|
|
3543
|
-
- --status=pending|verified|disputed|settled
|
|
3544
|
-
- --limit=<N> (1..100, default 20)
|
|
3545
|
-
- --json (raw JSON)
|
|
3546
|
-
- verify <orderId> (confirm delivery or trigger AI judge)
|
|
3547
|
-
- --action=confirm|ai_judge (default confirm)
|
|
3548
|
-
- atp-complete (internal: spawned Cursor sub-session uses this to settle an ATP task)
|
|
3549
|
-
- --task-id=<tid> (Hub task id, required)
|
|
3550
|
-
- --order-id=<oid> (ATP DeliveryProof id, required)
|
|
3551
|
-
- --answer-file=<path> (file containing the merchant answer, required)
|
|
3552
|
-
- --summary="..." (capsule summary, optional)
|
|
3553
|
-
- --capabilities=a,b (listing capabilities, optional)
|
|
3554
|
-
- --signals=s1,s2 (task signals, optional)
|
|
3555
|
-
|
|
3556
|
-
Validator role (decentralized validation, default ON since v1.69.0):
|
|
3557
|
-
- EVOLVER_VALIDATOR_ENABLED=0 opt out (env beats persisted flag and default)
|
|
3558
|
-
- EVOLVER_VALIDATOR_ENABLED=1 explicitly opt in
|
|
3559
|
-
- unset honor persisted flag from ~/.evomap/feature_flags.json,
|
|
3560
|
-
else default ON. The hub may push a flag update via
|
|
3561
|
-
the mailbox (event type: feature_flag_update).
|
|
3562
|
-
- Earnings: validators earn credits + reputation from successful consensus.
|
|
3563
|
-
See docs/validator.md for details.`);
|
|
3564
|
-
}
|
|
3565
|
-
}
|
|
3566
|
-
|
|
3567
|
-
if (require.main === module) {
|
|
3568
|
-
main().catch(function (err) {
|
|
3569
|
-
console.error('[FATAL] Top-level error:', err && err.stack ? err.stack : String(err));
|
|
3570
|
-
process.exitCode = 1;
|
|
3571
|
-
});
|
|
3572
|
-
}
|
|
3573
|
-
|
|
3574
|
-
module.exports = {
|
|
3575
|
-
main,
|
|
3576
|
-
readJsonSafe,
|
|
3577
|
-
rejectPendingRun,
|
|
3578
|
-
isPendingSolidify,
|
|
3579
|
-
parseBoolEnv,
|
|
3580
|
-
CycleTimeoutError,
|
|
3581
|
-
writeCycleProgressAtomic,
|
|
3582
|
-
handleCycleTimeout,
|
|
3583
|
-
observeTimedOutEvolvePromise,
|
|
3584
|
-
waitForTimedOutEvolvePromise,
|
|
3585
|
-
spawnReplacementProcess,
|
|
3586
|
-
};
|
|
1
|
+
export * from '@evomap/evolver-cli';
|